@jwilger/pi-development-system 0.9.0 → 0.10.1

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.
@@ -3,17 +3,22 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
3
3
  import { appendContextTail, renderContextTail } from "../src/context/context-tail.ts";
4
4
  import { renderStatus, renderStatusLine, STATUS_KEY } from "../src/context/status.ts";
5
5
  import { applyPromptSection } from "../src/context/system-prompt.ts";
6
+ import { detectProfiles } from "../src/core/profile.ts";
6
7
  import { createApprovalStore } from "../src/gates/approvals.ts";
7
8
  import { registerCommitGuard } from "../src/gates/commit-guard.ts";
8
9
  import { registerGitGuard } from "../src/gates/git-guard.ts";
10
+ import { registerLintSuppressionGuard } from "../src/gates/lint-suppression-guard.ts";
9
11
  import { registerPushGuard } from "../src/gates/push-guard.ts";
10
12
  import { createRecordDepartureTool } from "../src/gates/record-departure-tool.ts";
13
+ import { registerRedFirstGuard } from "../src/gates/red-first-guard.ts";
11
14
  import { createRequestApprovalTool } from "../src/gates/request-approval-tool.ts";
12
15
  import { registerTestGuard } from "../src/gates/test-guard.ts";
13
16
  import { createJevHolder } from "../src/jev/holder.ts";
14
17
  import { registerCiCommand } from "../src/state/ci-command.ts";
18
+ import { loadConfig } from "../src/state/config.ts";
15
19
  import { createModelsTool, registerModelsCommand } from "../src/state/models-command.ts";
16
20
  import { createSessionState } from "../src/state/session-state.ts";
21
+ import { registerTestEvidence } from "../src/state/test-evidence.ts";
17
22
 
18
23
  const nonNegotiables = readFileSync(
19
24
  new URL("../principles/NON-NEGOTIABLES.md", import.meta.url),
@@ -33,7 +38,15 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
33
38
  lastCtx?.ui.setStatus(STATUS_KEY, renderStatusLine(state.get(), jevModel));
34
39
  };
35
40
 
36
- const rebuild = (ctx: ExtensionContext) => {
41
+ const refreshProfiles = async (ctx: ExtensionContext) => {
42
+ const config = await loadConfig(ctx.cwd);
43
+ const override = config.ok ? config.value.profiles.override : [];
44
+ const profiles = await detectProfiles(ctx.cwd, override);
45
+ const current = state.get().profiles ?? [];
46
+ if (profiles.join() !== current.join()) state.update((s) => ({ ...s, profiles }));
47
+ };
48
+
49
+ const rebuild = async (ctx: ExtensionContext) => {
37
50
  lastCtx = ctx;
38
51
  const entries = ctx.sessionManager
39
52
  .getBranch()
@@ -45,6 +58,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
45
58
  jevModel = probe.model;
46
59
  state.update((s) => (s.jev === probe.availability ? s : { ...s, jev: probe.availability }));
47
60
  refreshStatus();
61
+ await refreshProfiles(ctx);
48
62
  };
49
63
 
50
64
  state.onChange(refreshStatus);
@@ -80,6 +94,9 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
80
94
  exec: (command, args, options) => pi.exec(command, args, options),
81
95
  });
82
96
  registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
97
+ registerTestEvidence({ pi, state });
98
+ registerRedFirstGuard({ pi, state });
99
+ registerLintSuppressionGuard({ pi, state });
83
100
  pi.registerTool(createRecordDepartureTool({ pi, state }));
84
101
  pi.registerTool(createRequestApprovalTool({ pi, approvals }));
85
102
  pi.registerTool(createModelsTool());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: behaviour-tests
3
+ description: Tests that assert observable behaviour through public interfaces, built as vertical slices, never against committed file text or internal structure. Use when writing or reviewing a test, deciding what to assert, or when a refactor breaks tests that should not care.
4
+ ---
5
+
6
+ # Behaviour tests
7
+
8
+ A test is an executable statement of behaviour a user of the code could observe.
9
+ It should survive any refactor that keeps that behaviour.
10
+
11
+ ## Rules
12
+
13
+ - **Black-box.** Call the public interface; assert on outputs and observable effects.
14
+ If the test must reach into private state, the interface is missing something.
15
+ - **Vertical slices.** Each test drives one user-visible capability from entry point to
16
+ outcome, with real core code and only the *outside world* faked (exec, clock, UI).
17
+ - **Name the behaviour,** not the function: "a commit with a Co-Authored-By trailer is
18
+ blocked", not "test checkCommit 3".
19
+ - **One reason to fail** per test, and a failure message that says what was expected.
20
+ - **Table tests for pure rules:** input, expected value, one row per case, including
21
+ edge and hostile inputs (empty, huge, malformed, adversarial).
22
+ - **Tests go beside the code they prove** and run in the default test command.
23
+
24
+ ## Desiderata (Beck) to trade off consciously
25
+
26
+ Tests should be isolated, deterministic, fast, readable, behavioural, structure-
27
+ insensitive and specific about failures. When two conflict, say which you picked.
28
+
29
+ ## Do not
30
+
31
+ - Do not assert on committed file text or structure ("README contains X",
32
+ "file has 40 lines"). It pins wording, not behaviour, and breaks on every edit.
33
+ Exception: a lint test that checks a *contract* (skill frontmatter, referenced paths).
34
+ - Do not mock the unit under test, or assert that an internal function was called.
35
+ - Do not share mutable state between tests or rely on order.
36
+ - Do not snapshot big outputs you did not read. A snapshot nobody reviewed asserts nothing.
37
+ - Do not delete or skip a test to get green. Fix the code (gate `tests.weaken`).
38
+
39
+ ## Checklist
40
+
41
+ - [ ] Asserts on outputs/effects through the public interface
42
+ - [ ] Named after the behaviour
43
+ - [ ] Only the outside world is faked
44
+ - [ ] Includes edge and hostile inputs for pure rules
45
+ - [ ] Survives a behaviour-preserving refactor
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: functional-core-imperative-shell
3
+ description: Functional core, imperative shell - keep decisions in pure functions and push all I/O to a thin outer layer that executes the effects. Use when designing a module, deciding where logic belongs, or when a test needs mocks or a real filesystem/network to check a business rule.
4
+ ---
5
+
6
+ # Functional core, imperative shell
7
+
8
+ Decisions are data-in, data-out functions with no I/O. The shell reads the world,
9
+ calls the core, and performs what the core returned. This is what makes behaviour
10
+ testable without mocks and keeps the part that changes most (policy) easy to change.
11
+
12
+ ## Shape
13
+
14
+ - **Core** (`src/core/`): pure. Takes values, returns values (including *descriptions*
15
+ of effects). No `fs`, network, clock, randomness, environment, or logging.
16
+ - **Shell** (`src/<adapter>/`, entry points): reads input, calls the core, runs the
17
+ returned effects, maps results to the outside world. Holds no business rules.
18
+ - **Effects as data:** when the core needs "then do X, then decide Y", return a
19
+ step the shell executes and feeds back (a `Step`/continuation or a plain list of
20
+ commands) rather than calling X itself.
21
+ - **Time, ids and randomness are inputs.** Pass `now`/`newId` in; tests then pass
22
+ fixed values.
23
+
24
+ ## Do not
25
+
26
+ - Do not call `Date.now()`, read env vars or touch the filesystem inside core logic.
27
+ The test for the rule then needs a fake clock or temp dir, which means the rule is
28
+ not isolated.
29
+ - Do not put a conditional business rule in the shell "because it is only one if".
30
+ Rules accrete; the shell becomes the place nobody can test.
31
+ - Do not mock the core to test the shell. Give the shell real core code and fake only
32
+ the outside world (the exec function, the clock, the UI).
33
+ - Do not let core types import shell types. Dependencies point inward only.
34
+
35
+ ## Test consequence
36
+
37
+ Core tests are plain table tests: input in, expected value out, no setup. If a core
38
+ test needs setup, something impure leaked in. Shell tests check wiring: given this
39
+ outside-world response, the right effect was executed.
40
+
41
+ ## Checklist
42
+
43
+ - [ ] The rule lives in a function with no I/O imports
44
+ - [ ] Time, ids and randomness arrive as parameters
45
+ - [ ] The shell contains no branching on business conditions
46
+ - [ ] Core tests need no mocks, temp files or network
47
+ - [ ] No inward-pointing import from core to shell
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: profile-rust
3
+ description: Rust idioms for this development system - nutype-style semantic types, thiserror error enums, strict clippy, cargo-mutants policy, test layout. Use when working in a Rust repo (Cargo.toml, *.rs files), adding a crate, or reviewing Rust code.
4
+ ---
5
+
6
+ # Rust profile
7
+
8
+ Applies the universal skills (`tdd-canon`, `functional-core-imperative-shell`,
9
+ `semantic-types`, `typed-errors`, `strict-lints`, `behaviour-tests`) with Rust idioms.
10
+ Read the reference you need, not all of them:
11
+
12
+ - Semantic types: `references/types.md`
13
+ - Errors: `references/errors.md`
14
+ - Lints: `references/lints.md`
15
+ - Tests and mutation testing: `references/tests.md`
16
+
17
+ ## Defaults
18
+
19
+ - **Newtypes with a fallible constructor** for every domain value; no public field
20
+ that lets a caller skip validation.
21
+ - **`Result<T, E>` everywhere** a caller can recover; `thiserror` enums, one per module.
22
+ `anyhow` only in binaries' `main`, never in library code.
23
+ - **Core is pure.** Effects are a returned `Step`/command enum executed by a thin
24
+ `main`/adapter layer; the core crate has no `std::fs`, `std::time` or `std::env`.
25
+ - **Pedantic clippy as errors** in CI; every `#[allow]` has a reason
26
+ (`#[allow(..., reason = "...")]` or a trailing comment).
27
+ - **No `unwrap`/`expect`/`panic!`/indexing in non-test code** unless it is a proven
28
+ invariant with a comment naming the proof.
29
+
30
+ ## Do not
31
+
32
+ - Do not use `String`/`PathBuf`/`u64` for domain values with rules. Name the newtype.
33
+ - Do not `#[allow(clippy::...)]` at crate level to quiet a few warnings.
34
+ - Do not `unwrap()` on input that came from outside the process.
35
+ - Do not make types `pub` just to test them; test through the public interface.
36
+
37
+ ## Checklist
38
+
39
+ - [ ] New domain values are newtypes with fallible constructors
40
+ - [ ] Errors are `thiserror` enums with stable kebab-case ids in `Display`/`kind()`
41
+ - [ ] `cargo clippy --all-targets -- -D warnings` clean
42
+ - [ ] `cargo test` green after a failing test was seen first
43
+ - [ ] Mutation testing run on changed core logic (see `references/tests.md`)
@@ -0,0 +1,18 @@
1
+ # Errors in Rust
2
+
3
+ ```rust
4
+ #[derive(Debug, thiserror::Error)]
5
+ pub enum ConfigError {
6
+ #[error("unknown key {key}")]
7
+ UnknownKey { key: String },
8
+ #[error("cannot read config")]
9
+ Io(#[from] std::io::Error),
10
+ }
11
+ ```
12
+
13
+ - One enum per module; variants carry the data a caller can act on.
14
+ - Stable id for tests/logs: add `fn kind(&self) -> &'static str` returning kebab-case
15
+ (`"unknown-key"`); do not match on message text.
16
+ - Libraries return `Result`; only a binary's `main` may use `anyhow` to print a report.
17
+ - `?` for propagation; `map_err` to translate at a boundary, keeping the cause via `#[from]`/`#[source]`.
18
+ - Panic only for broken invariants (Wlaschin exception: fail fast), with a comment.
@@ -0,0 +1,22 @@
1
+ # Lints for Rust
2
+
3
+ `Cargo.toml` (workspace):
4
+
5
+ ```toml
6
+ [workspace.lints.rust]
7
+ unsafe_code = "forbid"
8
+
9
+ [workspace.lints.clippy]
10
+ all = { level = "deny", priority = -1 }
11
+ pedantic = { level = "deny", priority = -1 }
12
+ unwrap_used = "deny"
13
+ expect_used = "deny"
14
+ panic = "deny"
15
+ indexing_slicing = "deny"
16
+ ```
17
+
18
+ - Run `cargo clippy --all-targets --all-features -- -D warnings` in CI and the pre-commit hook.
19
+ - Prefer `#[expect(lint, reason = "...")]` over `#[allow]`: it fails when the lint stops firing.
20
+ - Test modules may relax `unwrap_used`/`expect_used` with
21
+ `#![cfg_attr(test, allow(clippy::unwrap_used, reason = "tests assert by panicking"))]`.
22
+ - `cargo fmt --check` in CI; no hand-formatting arguments.
@@ -0,0 +1,10 @@
1
+ # Tests in Rust
2
+
3
+ - Unit tests of pure rules: `#[cfg(test)] mod tests` beside the code, table style.
4
+ - Behaviour/vertical-slice tests: `tests/*.rs` (integration), public API only.
5
+ - Prefer `proptest` for parsers and state machines (invariants over examples).
6
+ - `cargo test` is the RED/GREEN signal the extension watches; also `cargo nextest run`.
7
+ - **Mutation testing (`cargo-mutants`)**: run on changed core logic before review
8
+ (`cargo mutants --in-diff <(git diff main)`). A surviving mutant means a missing
9
+ assertion: add the test, do not exclude the mutant. Excluding needs a reason comment.
10
+ - Do not test private functions by making them `pub`; test through the interface.
@@ -0,0 +1,22 @@
1
+ # Semantic types in Rust
2
+
3
+ ```rust
4
+ #[derive(Debug, Clone, PartialEq, Eq)]
5
+ pub struct GateId(String);
6
+
7
+ impl GateId {
8
+ pub fn parse(input: &str) -> Result<Self, GateIdError> {
9
+ // validate once; the field is private so every GateId is valid
10
+ if is_valid(input) { Ok(Self(input.to_owned())) } else { Err(GateIdError::Malformed) }
11
+ }
12
+ pub fn as_str(&self) -> &str { &self.0 }
13
+ }
14
+ ```
15
+
16
+ - The `nutype` crate generates this (`#[nutype(sanitize(trim), validate(not_empty), derive(Debug, Clone, PartialEq))]`);
17
+ adding it is a dependency decision (ADR).
18
+ - Model states as enums, data in the variant that owns it:
19
+ `enum Slice { Draft{..}, InProgress{ started: Instant }, Done{ at: Instant } }`.
20
+ - Transitions are functions `fn advance(self, ev: Event) -> Result<Slice, TransitionError>`.
21
+ - Use `#[non_exhaustive]` on public enums that will grow.
22
+ - Parse at the boundary with `serde` into a raw DTO, then `TryFrom<Dto> for Domain`.
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: profile-typescript
3
+ description: TypeScript idioms for this development system - branded types with parse functions, Result-style errors, strict tsconfig and biome, node:test layout, ESM. Use when working in a TypeScript or JavaScript repo (package.json, tsconfig.json, *.ts files) or reviewing such code.
4
+ ---
5
+
6
+ # TypeScript profile
7
+
8
+ Applies the universal skills (`tdd-canon`, `functional-core-imperative-shell`,
9
+ `semantic-types`, `typed-errors`, `strict-lints`, `behaviour-tests`) with TypeScript idioms.
10
+ Read the reference you need:
11
+
12
+ - Branded types and parsing: `references/types.md`
13
+ - Result pattern: `references/errors.md`
14
+ - tsconfig and biome: `references/lints.md`
15
+ - Test layout: `references/tests.md`
16
+
17
+ ## Defaults
18
+
19
+ - **`strict` TypeScript, ESM, no `any`**, no `as` outside a boundary parse function
20
+ (commented as the boundary).
21
+ - **Branded types** for ids, paths and statuses, each with a `parseX(input: unknown)`
22
+ returning `X | ParseError` (or `Result`).
23
+ - **Discriminated unions** (`kind` field) for states and errors; exhaustive `switch`
24
+ with a `never` check.
25
+ - **Result values, not exceptions**, for expected failure; throw only for bugs.
26
+ - **Pure core in `src/core`**; I/O (fs, network, `process`) only in adapters and the
27
+ extension entry point.
28
+ - **`readonly` by default**; no mutation of inputs.
29
+
30
+ ## Do not
31
+
32
+ - Do not use `any`, non-null `!`, or `as Foo` to silence the compiler in core code.
33
+ - Do not use `enum`; use string-literal unions.
34
+ - Do not export mutable module state.
35
+ - Do not add a runtime dependency without an ADR; prefer Node built-ins.
36
+
37
+ ## Checklist
38
+
39
+ - [ ] `tsc --noEmit` clean under `strict` (plus `noUncheckedIndexedAccess`)
40
+ - [ ] `biome check .` clean; every suppression has a reason
41
+ - [ ] New ids/paths are branded with a parse function
42
+ - [ ] Failures returned as values with a `kind`
43
+ - [ ] Test written and seen failing before the source change
@@ -0,0 +1,15 @@
1
+ # Result pattern in TypeScript
2
+
3
+ ```ts
4
+ export type Result<T, E> =
5
+ | { readonly ok: true; readonly value: T }
6
+ | { readonly ok: false; readonly error: E };
7
+ export const ok = <T>(value: T): Result<T, never> => ({ ok: true, value });
8
+ export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
9
+ ```
10
+
11
+ - `E` is a closed union of `{ kind: "kebab-case-id"; ...data }` variants.
12
+ - Early return on failure: `if (!r.ok) return r;`.
13
+ - Never `catch` and return `false`; convert to a typed error with the cause as data.
14
+ - Throw only for the five Wlaschin exceptions, with a comment naming which.
15
+ - Guards and hooks must never throw into their host: return a safe decision instead.
@@ -0,0 +1,13 @@
1
+ # Lints for TypeScript
2
+
3
+ `tsconfig.json` compiler options: `"strict": true`, `"noUncheckedIndexedAccess": true`,
4
+ `"exactOptionalPropertyTypes": true`, `"noImplicitOverride": true`,
5
+ `"verbatimModuleSyntax": true`, `"noEmit": true`.
6
+
7
+ `biome.json`: enable `recommended` rules, plus `suspicious/noExplicitAny`,
8
+ `style/noNonNullAssertion`, `complexity/noExcessiveCognitiveComplexity` as errors.
9
+
10
+ - Run `tsc --noEmit` and `biome check .` in CI and the pre-commit hook.
11
+ - Suppressions: `// biome-ignore lint/<group>/<rule>: <reason of 15+ characters>`;
12
+ prefer `@ts-expect-error: <reason>` over `@ts-ignore`.
13
+ - Do not widen `include`/`exclude` to hide failing files.
@@ -0,0 +1,9 @@
1
+ # Tests in TypeScript
2
+
3
+ - Runner: `node --test "test/**/*.test.ts"` (Node strips types) or vitest if the repo already uses it.
4
+ - Layout mirrors `src/`: `test/core/foo.test.ts` for `src/core/foo.ts`.
5
+ - Pure core: table tests with `node:assert/strict`.
6
+ - Shell/adapters: inject the outside world (an `exec` function, a clock, a UI) and assert effects.
7
+ - Test names are sentences about behaviour.
8
+ - `npm test` / `node --test` is the RED/GREEN signal the extension watches.
9
+ - Do not `.skip`/`.only` and commit; the `tests.weaken` gate will ask for a departure.
@@ -0,0 +1,21 @@
1
+ # Semantic types in TypeScript
2
+
3
+ ```ts
4
+ export type GateId = string & { readonly __brand: "GateId" };
5
+ const GATE_ID = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/;
6
+
7
+ export type ParseError = { readonly kind: "parse-error"; readonly message: string };
8
+
9
+ /** The one place the brand is asserted: after the regex has proven the shape. */
10
+ export function parseGateId(input: string): GateId | ParseError {
11
+ return GATE_ID.test(input)
12
+ ? (input as GateId)
13
+ : { kind: "parse-error", message: `invalid gate id "${input}"` };
14
+ }
15
+ ```
16
+
17
+ - Parse `unknown` JSON with a hand-written parser (or a schema lib *if* an ADR adopted one);
18
+ never `JSON.parse(x) as Foo`.
19
+ - Unions over optionals: `type Slice = { kind: "draft" } | { kind: "started"; at: string }`.
20
+ - Exhaustiveness: `default: { const _: never = value; return _; }`.
21
+ - Use `readonly` and `ReadonlyArray` in public types.
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: semantic-types
3
+ description: Parse, don't validate - turn raw input into types that prove their own validity, and make illegal states unrepresentable. Use when modelling a domain, accepting external input (JSON, CLI args, files, env), writing a state machine, or seeing a string/number standing in for an id, path or status.
4
+ ---
5
+
6
+ # Semantic types
7
+
8
+ The type is the proof. Validate once at the boundary and produce a value the compiler
9
+ knows is valid; everything inside the boundary then needs no re-checking and cannot
10
+ be handed something invalid.
11
+
12
+ ## Rules
13
+
14
+ - **Parse at the edge.** One function per boundary takes `unknown` (or a raw string)
15
+ and returns either the typed value or a typed error. This is the only place a cast
16
+ (`as`) or `any` may appear, and it has a comment saying so.
17
+ - **No primitive obsession.** A branded/newtype `GateId`, `SliceRef`, `RepoPath` beats
18
+ `string`. Two ids of different things must not be assignable to each other.
19
+ - **Make illegal states unrepresentable.** If a field only makes sense in one state,
20
+ put it in that variant. "Has an email *or* a postal address" is a union, not two
21
+ optional fields and a runtime check.
22
+ - **Explicit state machines.** States are a union of variants; transitions are
23
+ functions `(State, Event) -> State`. No boolean flag combinations.
24
+ - **Constructors that can fail return a result** (`T | ParseError`, `Result<T, E>`),
25
+ never throw for expected bad input.
26
+
27
+ ## Do not
28
+
29
+ - Do not validate the same invariant in three places. If you re-check it, the type
30
+ is not carrying the proof; strengthen the type.
31
+ - Do not use optional fields to model "sometimes present". Use a union keyed by a
32
+ discriminant (`kind`).
33
+ - Do not cast to silence the compiler inside the core. A cast there hides a missing
34
+ parse step.
35
+ - Do not add a new `string` field for something with rules (an id, a path, a status).
36
+ Name the type first.
37
+
38
+ ## Checklist
39
+
40
+ - [ ] Every external input passes through one parse function
41
+ - [ ] Parse functions return typed errors; only they cast
42
+ - [ ] Ids/paths/statuses are distinct types, not bare strings
43
+ - [ ] Mutually exclusive data lives in a discriminated union
44
+ - [ ] State transitions are total functions over a union
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: strict-lints
3
+ description: Strict linting with reasoned suppressions - lints are errors, and a suppression always says why. Use when a linter or type-checker complains, when tempted to add an ignore/allow/disable comment, or when configuring lint rules for a project.
4
+ ---
5
+
6
+ # Strict lints
7
+
8
+ Lints and type errors are failing checks, not suggestions. A suppression hides a
9
+ finding from every later reader, so it must carry its reason.
10
+
11
+ ## Rules
12
+
13
+ - **Fix the finding first.** Most lint complaints are right. Suppress only when the
14
+ rule is wrong *here*, and be able to say why in one line.
15
+ - **Every suppression has a reason** of at least 15 characters on the same line or the
16
+ next: `// biome-ignore lint/suspicious/noExplicitAny: boundary parse of foreign JSON`,
17
+ `#[allow(clippy::too_many_arguments)] // mirrors the C ABI exactly`,
18
+ `#[allow(..., reason = "...")]`.
19
+ - **Narrowest scope.** One line, never a whole file or crate, unless the whole file
20
+ is generated.
21
+ - **Prefer `expect` over `allow`** where the language has it (`#[expect]`,
22
+ `@ts-expect-error`): it fails when the suppression stops being needed.
23
+ - **Lints are configured strict from day one** (see the language profile): pedantic
24
+ clippy; biome recommended-plus; TypeScript `strict`. Loosening a project rule is a
25
+ recorded decision, not an edit.
26
+
27
+ ## What the extension does
28
+
29
+ Editing a file so that it adds `#[allow(`, `#[expect(`, `// biome-ignore`,
30
+ `// eslint-disable`, `@ts-ignore`, `@ts-expect-error` or `@ts-nocheck` without such
31
+ a reason is blocked with gate `lints.suppression`. Add the reason, or record a
32
+ departure naming why a bare one is right. Suppressions already in the file are not re-flagged.
33
+
34
+ ## Do not
35
+
36
+ - Do not suppress to make CI green. That is weakening verification (non-negotiable 2).
37
+ - Do not write a reason that restates the rule ("ignore the lint"). It must explain
38
+ why the code is correct despite the rule.
39
+ - Do not disable a rule project-wide because three files trip it. Fix the three.
40
+
41
+ ## Checklist
42
+
43
+ - [ ] Tried to fix the finding before suppressing
44
+ - [ ] Suppression is one line with a reason of 15+ characters
45
+ - [ ] `expect` used where available
46
+ - [ ] No lint rule loosened in project config without a recorded decision
@@ -0,0 +1,71 @@
1
+ ---
2
+ name: tdd-canon
3
+ description: Test-driven development the way this system does it - a test list, one failing test at a time, minimal code to pass, structural changes kept apart from behavioural ones. Use before changing production behaviour, when deciding whether a change needs a RED test, or when tempted to write tests after the code.
4
+ ---
5
+
6
+ # TDD (Canon)
7
+
8
+ The extension watches for this: while a slice is `implementing`, editing production
9
+ source after a green (or absent) test run asks for a `tdd.red-first` departure.
10
+ This skill is how to work so that gate never fires.
11
+
12
+ ## The loop
13
+
14
+ 1. **Write a test list** for the behaviour (plain bullets, in a scratch file or the
15
+ task record). Add to it as you learn; never finish the list from memory.
16
+ 2. **Turn exactly one item into a runnable test.** Deciding the test is where you
17
+ design the interface: call the code the way a user of it would.
18
+ 3. **Run it and watch it fail (RED) for the right reason.** A test that fails on a
19
+ typo or missing import has proven nothing.
20
+ 4. **Write the least code that passes** this test and all earlier ones (GREEN).
21
+ 5. **Refactor** only while green, as its own *structural* step.
22
+ 6. Cross the item off. Repeat until the list is empty.
23
+
24
+ ## Do not
25
+
26
+ - Do not write several tests, then the code. One failing test at a time keeps each
27
+ step small enough to be wrong cheaply.
28
+ - Do not write the code first and "add tests after". The test then describes what
29
+ the code does, not what it should do, so it cannot catch the bug you just wrote.
30
+ - Do not make a failing test pass by weakening it (skip, delete, loosen an expected
31
+ value, comment out). A green gate must mean the same thing afterwards; this is
32
+ non-negotiable 2 and the `tests.weaken` gate.
33
+ - Do not mix structural and behavioural changes in one commit (Tidy First). Rename,
34
+ move, reformat: commit. Then change behaviour: commit. Reviewers cannot see a
35
+ behaviour change inside refactoring noise.
36
+ - Do not add behaviour nobody asked for because "it will be needed". Unrequested
37
+ functionality is a warning sign of drift; scope is the steering wheel.
38
+
39
+ ## When RED is not required
40
+
41
+ RED is for **adding or changing first-party production behaviour** when no existing
42
+ failing test already proves the required change. These are exempt. Docs, config, test and generated files are
43
+ exempt by path, so the gate never asks. For the rest, *name* the exemption in a
44
+ recorded departure (scope `slice`; one departure covers the slice):
45
+
46
+ - docs or metadata only
47
+ - functionality removal (the proof is that nothing still depends on it)
48
+ - documented third-party behaviour you are only wiring up
49
+ - a test of committed-file text or structure (don't write such tests at all)
50
+ - a change already shown by a failing test you have just seen
51
+ - straightforward CI workflow scripting, where running CI is the test
52
+ - a simple development-environment utility
53
+ - a behaviour-preserving refactor with adequate green coverage
54
+
55
+ "It is simple" is not an exemption. Simple changes are where untested regressions live.
56
+
57
+ ## Evidence the extension sees
58
+
59
+ It records each test-runner command (`cargo test`, `npm test`, `node --test`,
60
+ `vitest`, `pytest`, `go test`) with its exit code. After a *failing* run, source
61
+ edits flow freely; after a *passing* run, the next source edit needs a failing test
62
+ first. Run the new test before touching the source, and the evidence exists.
63
+
64
+ ## Checklist
65
+
66
+ - [ ] Test list written, one item chosen
67
+ - [ ] Test fails, and for the intended reason (read the failure)
68
+ - [ ] Minimal code, test passes, earlier tests still pass
69
+ - [ ] Refactor done separately, tests still green
70
+ - [ ] Structural and behavioural changes in separate commits
71
+ - [ ] If RED was skipped: the exemption is named in a recorded departure
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: typed-errors
3
+ description: Errors as values - expected failures are returned as typed results with stable kebab-case ids, not thrown; exceptions are for bugs. Use when writing a function that can fail, designing an error type, or choosing between returning a Result and throwing.
4
+ ---
5
+
6
+ # Typed errors
7
+
8
+ Failures the caller should handle are part of the function's type. They travel as
9
+ values; they are never strings, never `null` standing for "something went wrong".
10
+
11
+ ## Rules
12
+
13
+ - **Expected failure -> result.** `Result<T, E>` (or `T | ParseError`) where `E` is a
14
+ union of named variants with data the caller can act on.
15
+ - **Stable ids.** Each variant has a kebab-case `kind` (`config-error`, `no-model`,
16
+ `timeout`) that tests and logs can match on. The human message is separate.
17
+ - **One error type per module boundary**, a closed union. Wrapping a lower layer's
18
+ error keeps the cause as data.
19
+ - **Compose with early returns** (`if (!r.ok) return r`), not nested callbacks.
20
+ - **Never swallow.** Handle it, return it, or record why ignoring it is safe.
21
+
22
+ ## When to throw anyway (Wlaschin's five exceptions)
23
+
24
+ Do not use `Result` when:
25
+
26
+ 1. you need **diagnostics** (a stack trace beats a "glorified boolean");
27
+ 2. you would be **reinventing exceptions** for truly exceptional conditions;
28
+ 3. you need to **fail fast** because continuing would corrupt state;
29
+ 4. **nobody will see** the error (it cannot be surfaced anywhere);
30
+ 5. **nobody cares** (best-effort cleanup).
31
+
32
+ If you throw, name which of the five applies in a comment. If none applies, return a value.
33
+
34
+ ## Do not
35
+
36
+ - Do not throw for expected bad input (a malformed file, a missing option). The caller
37
+ cannot see it in the type and will forget to handle it.
38
+ - Do not catch and return a bare `false`/`undefined`; the reason is lost.
39
+ - Do not use error message text as the contract. Match on `kind`.
40
+ - Do not let a guard or hook crash its host: parse defensively and return a safe
41
+ decision (fail closed or ask) rather than throw.
42
+
43
+ ## Checklist
44
+
45
+ - [ ] Each fallible function returns a result with a closed error union
46
+ - [ ] Every error variant has a stable kebab-case `kind`
47
+ - [ ] No expected failure is thrown; each `throw` names its exception
48
+ - [ ] No error is swallowed without a stated reason
@@ -15,6 +15,7 @@ export function renderContextTail(state: DevsysState): string | undefined {
15
15
  "[development-system]",
16
16
  `phase: ${state.phase} · slice: ${state.activeSlice ?? "none"}`,
17
17
  `Jev: ${state.jev}`,
18
+ ...((state.profiles?.length ?? 0) > 0 ? [`profiles: ${state.profiles?.join(", ")}`] : []),
18
19
  ];
19
20
  const tailLines = [REMINDER];
20
21
  const room = MAX_LINES - head.length - tailLines.length - 1;
@@ -0,0 +1,131 @@
1
+ /** Pure detector for lint/type-check suppressions that carry no written reason (gate `lints.suppression`). */
2
+
3
+ export type Suppression = {
4
+ /** 1-based line in the text the suppression was found in. */
5
+ readonly line: number;
6
+ readonly marker: string;
7
+ readonly text: string;
8
+ };
9
+
10
+ /** A reason shorter than this says nothing a reader could use. */
11
+ export const MIN_RATIONALE = 15;
12
+
13
+ type Kind = {
14
+ readonly marker: string;
15
+ readonly pattern: RegExp;
16
+ /** The reason written on the marker's own line, if any. */
17
+ readonly inline: (line: string, after: string) => string | undefined;
18
+ };
19
+
20
+ const lastGroup = (re: RegExp, text: string): string | undefined => re.exec(text)?.[1];
21
+
22
+ const KINDS: readonly Kind[] = [
23
+ {
24
+ marker: "#[allow(",
25
+ pattern: /#!?\[allow\(/,
26
+ inline: (line, after) =>
27
+ lastGroup(/reason\s*=\s*"([^"]*)"/, line) ??
28
+ lastGroup(/\)\]\s*\/\/\s*(.+)$/, after) ??
29
+ lastGroup(/\)\]\s*\/\*\s*(.+?)\s*\*\/\s*$/, after),
30
+ },
31
+ {
32
+ marker: "#![allow(",
33
+ pattern: /#!\[allow\(/,
34
+ inline: () => undefined,
35
+ },
36
+ {
37
+ marker: "#[expect(",
38
+ pattern: /#!?\[expect\(/,
39
+ inline: (line, after) =>
40
+ lastGroup(/reason\s*=\s*"([^"]*)"/, line) ?? lastGroup(/\)\]\s*\/\/\s*(.+)$/, after),
41
+ },
42
+ {
43
+ marker: "biome-ignore",
44
+ pattern: /\/\/\s*biome-ignore(?:-all|-start|-end)?\b/,
45
+ inline: (_line, after) => lastGroup(/:\s*(.+)$/, after),
46
+ },
47
+ {
48
+ marker: "eslint-disable",
49
+ pattern: /(?:\/\/|\/\*)\s*eslint-disable(?:-next-line|-line)?\b/,
50
+ inline: (_line, after) => lastGroup(/(?:\s--\s+|:\s+)(.+?)(?:\s*\*\/)?\s*$/, after),
51
+ },
52
+ {
53
+ marker: "@ts-ignore",
54
+ pattern: /@ts-ignore\b/,
55
+ inline: (_line, after) => lastGroup(/^\s*:?\s*(.+?)(?:\s*\*\/)?\s*$/, after),
56
+ },
57
+ {
58
+ marker: "@ts-expect-error",
59
+ pattern: /@ts-expect-error\b/,
60
+ inline: (_line, after) => lastGroup(/^\s*:?\s*(.+?)(?:\s*\*\/)?\s*$/, after),
61
+ },
62
+ {
63
+ marker: "@ts-nocheck",
64
+ pattern: /@ts-nocheck\b/,
65
+ inline: (_line, after) => lastGroup(/^\s*:?\s*(.+?)(?:\s*\*\/)?\s*$/, after),
66
+ },
67
+ ];
68
+
69
+ const COMMENT_LINE = /^\s*(?:\/\/+|\/\*+|\*|#+)\s*(.*?)(?:\s*\*\/)?\s*$/;
70
+
71
+ const isSuppressionLine = (line: string): boolean => KINDS.some((k) => k.pattern.test(line));
72
+
73
+ /** Prose of a neighbouring comment line, unless that line is itself a suppression marker. */
74
+ function neighbourReason(line: string | undefined): string | undefined {
75
+ if (line === undefined || isSuppressionLine(line)) return undefined;
76
+ return COMMENT_LINE.exec(line)?.[1];
77
+ }
78
+
79
+ const long = (reason: string | undefined): boolean =>
80
+ reason !== undefined && reason.trim().length >= MIN_RATIONALE;
81
+
82
+ function kindOf(line: string): Kind | undefined {
83
+ // `#![allow(` also matches the `#[allow(` shape; the inner form is more specific and wins.
84
+ const matched = KINDS.filter((k) => k.pattern.test(line));
85
+ return matched.find((k) => k.marker === "#![allow(") ?? matched[0];
86
+ }
87
+
88
+ /** A rustfmt-wrapped `#[allow(\n lint,\n reason = "...",\n)]`: the reason sits on a later line. */
89
+ function wrappedReason(lines: readonly string[], index: number): boolean {
90
+ const attr = lines.slice(index, index + 8);
91
+ const end = attr.findIndex((l) => l.includes(")]"));
92
+ const text = (end < 0 ? attr : attr.slice(0, end + 1)).join(" ");
93
+ return long(lastGroup(/reason\s*=\s*"([^"]*)"/, text));
94
+ }
95
+
96
+ function reasonOf(kind: Kind, lines: readonly string[], index: number): boolean {
97
+ const line = lines[index] ?? "";
98
+ if (/^\s*#!?\[(?:allow|expect)\(\s*$/.test(line) && wrappedReason(lines, index)) return true;
99
+ const match = kind.pattern.exec(line);
100
+ const after = match === null ? "" : line.slice(match.index + match[0].length);
101
+ return (
102
+ long(kind.inline(line, after)) ||
103
+ long(neighbourReason(lines[index + 1])) ||
104
+ long(neighbourReason(lines[index - 1]))
105
+ );
106
+ }
107
+
108
+ /** Every suppression in `text` whose reason is missing or under {@link MIN_RATIONALE} characters. */
109
+ function unreasoned(text: string): Suppression[] {
110
+ const lines = text.split("\n");
111
+ return lines.flatMap((line, index): Suppression[] => {
112
+ const kind = kindOf(line);
113
+ if (kind === undefined || reasonOf(kind, lines, index)) return [];
114
+ return [{ line: index + 1, marker: kind.marker, text: line.trim() }];
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Unreasoned suppressions that `after` adds relative to `before`: a suppression whose text already
120
+ * occurred (as often) in `before` is not new, so touching a file never re-flags old debt.
121
+ */
122
+ export function findUnreasonedSuppressions(before: string, after: string): Suppression[] {
123
+ const known = new Map<string, number>();
124
+ for (const s of unreasoned(before)) known.set(s.text, (known.get(s.text) ?? 0) + 1);
125
+ return unreasoned(after).filter((s) => {
126
+ const left = known.get(s.text) ?? 0;
127
+ if (left === 0) return true;
128
+ known.set(s.text, left - 1);
129
+ return false;
130
+ });
131
+ }
@@ -0,0 +1,37 @@
1
+ import { isTestPath, type TestPathProfile } from "./test-paths.ts";
2
+
3
+ /** What kind of file a repo-relative path is, for gates that only care about production code. */
4
+ export type PathClass = "source" | "test" | "docs" | "config" | "generated" | "other";
5
+
6
+ const SOURCE_EXT =
7
+ /\.(?:[cm]?[jt]sx?|py|rb|rs|go|java|kt|kts|scala|cs|ex|exs|php|swift|c|cc|cpp|h|hpp|sh|lua|clj|hs|ml)$/;
8
+ const DOCS_EXT = /\.(?:md|mdx|rst|txt|adoc)$/i;
9
+ const DOCS_NAME = /^(?:LICENSE|LICENCE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING)(?:\..*)?$/i;
10
+ const CONFIG_EXT = /\.(?:json|jsonc|toml|ya?ml|ini|cfg|conf|env|properties)$/i;
11
+ const CONFIG_NAME = /^(?:Dockerfile|Makefile|justfile|Rakefile|Gemfile|Procfile)$/i;
12
+ // Build output directories only count at the repo root; `src/build/` is usually real source.
13
+ const GENERATED_DIR =
14
+ /(?:^|\/)(?:node_modules|__generated__|\.next)\/|^(?:dist|target|build|coverage|vendor|generated)\//;
15
+ const GENERATED_FILE =
16
+ /(?:\.generated\.|\.gen\.|\.min\.[cm]?js$|\.pb\.go$|\.snap$|(?:^|\/)(?:package-lock\.json|Cargo\.lock|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$|\.lock$)/;
17
+
18
+ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
19
+
20
+ /** Classifies a repo-relative path by convention; tests win over docs/config so test data stays guarded. */
21
+ export function classifyPath(input: string, profile?: TestPathProfile): PathClass {
22
+ const path = input.replace(/^\.\//, "");
23
+ const name = basename(path);
24
+ if (GENERATED_DIR.test(path) || GENERATED_FILE.test(path)) return "generated";
25
+ if (isTestPath(path, profile) && SOURCE_EXT.test(path)) return "test";
26
+ if (DOCS_EXT.test(name) || DOCS_NAME.test(name) || /^docs\//.test(path)) return "docs";
27
+ if (
28
+ CONFIG_EXT.test(name) ||
29
+ CONFIG_NAME.test(name) ||
30
+ name.startsWith(".") ||
31
+ /^\.github\//.test(path)
32
+ ) {
33
+ return "config";
34
+ }
35
+ if (SOURCE_EXT.test(path)) return "source";
36
+ return isTestPath(path, profile) ? "test" : "other";
37
+ }
@@ -0,0 +1,49 @@
1
+ import { access } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isParseError, type ParseError, type Profile, parseError } from "./types.ts";
4
+
5
+ export type { Profile };
6
+
7
+ export const PROFILES: readonly Profile[] = ["rust", "typescript"];
8
+
9
+ const MARKERS: ReadonlyArray<{ profile: Profile; files: readonly string[] }> = [
10
+ { profile: "rust", files: ["Cargo.toml"] },
11
+ { profile: "typescript", files: ["package.json", "tsconfig.json"] },
12
+ ];
13
+
14
+ export const isProfile = (value: string): value is Profile => PROFILES.some((p) => p === value);
15
+
16
+ /** Boundary parse for configured profile names. */
17
+ export function parseProfiles(names: readonly string[]): Profile[] | ParseError {
18
+ const out: Profile[] = [];
19
+ for (const name of names) {
20
+ if (!isProfile(name)) {
21
+ return parseError(`unknown profile "${name}": expected one of ${PROFILES.join(", ")}`);
22
+ }
23
+ out.push(name);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ const exists = (path: string): Promise<boolean> =>
29
+ access(path).then(
30
+ () => true,
31
+ () => false,
32
+ );
33
+
34
+ /** Profiles that apply to `repoRoot`; a non-empty `override` (valid names only) replaces detection. */
35
+ export async function detectProfiles(
36
+ repoRoot: string,
37
+ override: readonly string[] = [],
38
+ ): Promise<Profile[]> {
39
+ if (override.length > 0) {
40
+ const parsed = parseProfiles(override);
41
+ if (!isParseError(parsed)) return parsed;
42
+ }
43
+ const found: Profile[] = [];
44
+ for (const { profile, files } of MARKERS) {
45
+ const hits = await Promise.all(files.map((f) => exists(join(repoRoot, f))));
46
+ if (hits.some(Boolean)) found.push(profile);
47
+ }
48
+ return found;
49
+ }
@@ -0,0 +1,87 @@
1
+ import { basename, isAssignment, segments, splitLines, WRAPPERS } from "./git-intent.ts";
2
+ import { redactSecrets } from "./redact.ts";
3
+
4
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
5
+ const DIRECT_RUNNERS = new Set(["vitest", "jest", "pytest", "mocha", "ava", "tap"]);
6
+ const EXEC_PREFIX = new Set(["npx", "bunx", "pnpx"]);
7
+ const SUMMARY_LINES = 3;
8
+ const SUMMARY_MAX = 300;
9
+
10
+ /** Words after wrappers (`time`, `timeout 60`, `env`), assignments and `+toolchain` selectors. */
11
+ function commandWords(words: readonly string[]): string[] {
12
+ let i = 0;
13
+ while (i < words.length) {
14
+ const word = words[i] ?? "";
15
+ const wrapper = WRAPPERS.has(basename(word)) || basename(word) === "timeout";
16
+ if (isAssignment(word)) i += 1;
17
+ else if (wrapper) {
18
+ i += 1;
19
+ while (i < words.length && /^(?:-|\d+[smhd]?$)/.test(words[i] ?? "")) i += 1;
20
+ } else break;
21
+ }
22
+ return words.slice(i);
23
+ }
24
+
25
+ function isRunner(words: readonly string[]): boolean {
26
+ const [head, ...rest] = words.map((w, i) => (i === 0 ? basename(w) : w));
27
+ if (head === undefined) return false;
28
+ const args = rest.filter((w) => !w.startsWith("+"));
29
+ const first = args[0];
30
+ if (head === "cargo") return first === "test" || first === "nextest";
31
+ if (head === "go" || head === "deno") return first === "test";
32
+ if (head === "node") return rest.includes("--test");
33
+ if (head === "python" || head === "python3")
34
+ return first === "-m" && /^(?:pytest|unittest)$/.test(args[1] ?? "");
35
+ if (DIRECT_RUNNERS.has(head)) return true;
36
+ if (EXEC_PREFIX.has(head)) return DIRECT_RUNNERS.has(first ?? "");
37
+ if (PACKAGE_MANAGERS.has(head)) {
38
+ if (first === "test" || first === "t") return true;
39
+ return first === "run" && /^test(?::|$)/.test(args[1] ?? "");
40
+ }
41
+ return false;
42
+ }
43
+
44
+ /** Whether any simple command in `command` runs a test suite (`cargo test`, `npm test`, `vitest`, ...). */
45
+ export function isTestRunnerCommand(command: string): boolean {
46
+ return splitLines(command).some((line) =>
47
+ segments(line).some((words) => isRunner(commandWords(words))),
48
+ );
49
+ }
50
+
51
+ export type ResultLike = {
52
+ readonly isError: boolean;
53
+ readonly text: string;
54
+ readonly structured?: unknown;
55
+ /** The command that ran; used to tell whether its exit status could have been masked. */
56
+ readonly command?: string;
57
+ };
58
+
59
+ /** A pipe or sequence makes the shell report the last command's status, not the runner's. */
60
+ const MASKS_STATUS = /\||;/;
61
+ const FAILURE_MARKERS =
62
+ /^# fail [1-9]|test result: FAILED|\b[1-9]\d* (?:failed|failing)\b|^FAILED\b|^FAIL\b|\bnot ok\b/m;
63
+
64
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
65
+ typeof v === "object" && v !== null && !Array.isArray(v);
66
+
67
+ /** Exit code of a bash tool result: structured `exit_code`, else pi's status line, else error/not. */
68
+ export function exitCodeOf(result: ResultLike): number {
69
+ if (isRecord(result.structured) && typeof result.structured.exit_code === "number") {
70
+ return result.structured.exit_code;
71
+ }
72
+ const line = /Command exited with code (\d+)\s*$/.exec(result.text);
73
+ if (line?.[1] !== undefined) return Number(line[1]);
74
+ if (result.isError) return 1;
75
+ // `npm test | tail` exits 0 whatever the tests did; trust the output over the masked status.
76
+ const masked = result.command !== undefined && MASKS_STATUS.test(result.command);
77
+ return masked && FAILURE_MARKERS.test(result.text) ? 1 : 0;
78
+ }
79
+
80
+ /** The last few non-empty output lines, redacted and bounded: what the runner said at the end. */
81
+ export function summarizeOutput(output: string): string {
82
+ const lines = output
83
+ .split("\n")
84
+ .map((l) => l.trim())
85
+ .filter((l) => l !== "" && !/^Command exited with code \d+$/.test(l));
86
+ return redactSecrets(lines.slice(-SUMMARY_LINES).join(" | ")).slice(0, SUMMARY_MAX);
87
+ }
package/src/core/types.ts CHANGED
@@ -62,6 +62,16 @@ export const CI_STATUSES: readonly CiStatusName[] = ["green", "red", "pending",
62
62
  /** Last observed CI state of the trunk (see `/devsys-ci`). */
63
63
  export type CiState = { readonly status: CiStatusName; readonly sha?: string };
64
64
 
65
+ /** Language profile: universal principles plus the idioms of one language (decision D3). */
66
+ export type Profile = "rust" | "typescript";
67
+
68
+ /** Last observed test-runner invocation (see src/state/test-evidence.ts). */
69
+ export type TestRun = {
70
+ readonly at: string;
71
+ readonly exitCode: number;
72
+ readonly summary: string;
73
+ };
74
+
65
75
  export type DevsysState = {
66
76
  readonly phase: Phase;
67
77
  readonly sizing?: Sizing;
@@ -70,6 +80,8 @@ export type DevsysState = {
70
80
  readonly jev: JevStatus;
71
81
  readonly lastPushAt?: string;
72
82
  readonly ci?: CiState;
83
+ readonly profiles?: ReadonlyArray<Profile>;
84
+ readonly lastTestRun?: TestRun;
73
85
  };
74
86
 
75
87
  export const initialState = (): DevsysState => ({
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ type ExtensionAPI,
6
+ isToolCallEventType,
7
+ type ToolCallEvent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { findUnreasonedSuppressions, MIN_RATIONALE } from "../core/lint-suppression.ts";
10
+ import { classifyPath } from "../core/path-class.ts";
11
+ import { normalizeRepoPath } from "../core/test-paths.ts";
12
+ import { applyEdits, normalizeText } from "../core/test-weakening.ts";
13
+ import { type GateId, isParseError, parseGateId } from "../core/types.ts";
14
+ import type { SessionState } from "../state/session-state.ts";
15
+ import { departureUse } from "./departure-use.ts";
16
+
17
+ export type LintSuppressionGuardDeps = { pi: ExtensionAPI; state: SessionState };
18
+
19
+ const GATE_ID = "lints.suppression";
20
+
21
+ const readIfExists = (file: string): string => {
22
+ try {
23
+ return normalizeText(readFileSync(file, "utf8"));
24
+ } catch {
25
+ return "";
26
+ }
27
+ };
28
+
29
+ /** Before/after text of the file an edit or write would produce; `undefined` for other tools. */
30
+ function resultingText(
31
+ event: ToolCallEvent,
32
+ cwd: string,
33
+ ): { path: string; before: string; after: string } | undefined {
34
+ if (!isToolCallEventType("write", event) && !isToolCallEventType("edit", event)) return undefined;
35
+ const path = normalizeRepoPath(cwd, event.input.path, homedir());
36
+ const class_ = classifyPath(path);
37
+ if (class_ !== "source" && class_ !== "test") return undefined;
38
+ const before = readIfExists(join(cwd, path));
39
+ if (isToolCallEventType("write", event)) {
40
+ return { path, before, after: normalizeText(event.input.content) };
41
+ }
42
+ const applied = applyEdits(before, event.input.edits);
43
+ // When the edit text does not match, judge what the agent intends to write on its own.
44
+ return {
45
+ path,
46
+ before: applied === undefined ? "" : before,
47
+ after: applied ?? event.input.edits.map((e) => normalizeText(e.newText)).join("\n"),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Soft gate `lints.suppression`: a lint or type-check suppression needs a written reason
53
+ * (at least {@link MIN_RATIONALE} characters) or a recorded departure.
54
+ */
55
+ export function registerLintSuppressionGuard(deps: LintSuppressionGuardDeps): void {
56
+ const parsed = parseGateId(GATE_ID);
57
+ if (isParseError(parsed)) return;
58
+ const gate: GateId = parsed;
59
+ const departure = departureUse(deps.state, gate);
60
+
61
+ deps.pi.on("tool_call", (event, ctx) => {
62
+ const change = resultingText(event, ctx.cwd);
63
+ if (change === undefined) return undefined;
64
+ const found = findUnreasonedSuppressions(change.before, change.after);
65
+ if (found.length === 0) return undefined;
66
+ if (departure.hasOpen()) {
67
+ departure.consume();
68
+ return undefined;
69
+ }
70
+ const list = found.map((s) => `${s.marker} (line ${s.line})`).join(", ");
71
+ return {
72
+ block: true,
73
+ reason:
74
+ `${GATE_ID}: ${change.path} adds a suppression without a reason: ${list}. A suppression hides a ` +
75
+ `finding from every later reader, so it must say why (at least ${MIN_RATIONALE} characters, on the ` +
76
+ `same line or the next, e.g. "// biome-ignore lint/x: boundary parse of foreign JSON"). Add the ` +
77
+ `reason, or fix the finding instead. If a bare suppression is truly right, call ` +
78
+ `devsys_record_departure with gate "${GATE_ID}", what you are doing, why, and the cost if wrong; then retry.`,
79
+ };
80
+ });
81
+ }
@@ -0,0 +1,63 @@
1
+ import { homedir } from "node:os";
2
+ import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
3
+ import { classifyPath } from "../core/path-class.ts";
4
+ import { normalizeRepoPath } from "../core/test-paths.ts";
5
+ import { type GateId, isParseError, parseGateId } from "../core/types.ts";
6
+ import type { SessionState } from "../state/session-state.ts";
7
+ import { departureUse } from "./departure-use.ts";
8
+
9
+ export type RedFirstGuardDeps = { pi: ExtensionAPI; state: SessionState };
10
+
11
+ const GATE_ID = "tdd.red-first";
12
+
13
+ /** Test code written inside a source file (Rust `#[cfg(test)]`/`#[test]`): that edit is the RED step itself. */
14
+ const INLINE_TEST = /#\[(?:cfg\(test\)|test|tokio::test|rstest)\]/;
15
+
16
+ const newText = (
17
+ input: { content: string } | { edits: ReadonlyArray<{ newText: string }> },
18
+ ): string => ("content" in input ? input.content : input.edits.map((e) => e.newText).join("\n"));
19
+
20
+ /** Exemption classes a machine cannot see; the departure names which one applies (research 02). */
21
+ const JUDGED_EXEMPTIONS =
22
+ "functionality removal, documented third-party behaviour, a change already shown by a failing test, " +
23
+ "straightforward CI scripting, a simple dev-environment utility, or a behaviour-preserving refactor with green coverage";
24
+
25
+ /**
26
+ * Soft gate `tdd.red-first`: while implementing, production source is edited only after a failing
27
+ * test run has been observed. Test, docs, config and generated files are exempt by path; the
28
+ * judged exemptions go through one recorded departure, which covers its whole slice.
29
+ */
30
+ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
31
+ const parsed = parseGateId(GATE_ID);
32
+ if (isParseError(parsed)) return;
33
+ const gate: GateId = parsed;
34
+ const departure = departureUse(deps.state, gate);
35
+
36
+ deps.pi.on("tool_call", (event, ctx) => {
37
+ if (!isToolCallEventType("edit", event) && !isToolCallEventType("write", event))
38
+ return undefined;
39
+ const { phase, lastTestRun, activeSlice } = deps.state.get();
40
+ if (phase !== "implementing") return undefined;
41
+ if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
42
+ const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
43
+ if (classifyPath(path) !== "source") return undefined;
44
+ if (INLINE_TEST.test(newText(event.input))) return undefined;
45
+ if (departure.hasOpen()) {
46
+ departure.consume();
47
+ return undefined;
48
+ }
49
+ const seen =
50
+ lastTestRun === undefined
51
+ ? "no test run has been observed in this session"
52
+ : `the last test run passed (${lastTestRun.summary || "exit 0"})`;
53
+ const scope = activeSlice === undefined ? "session" : "slice";
54
+ return {
55
+ block: true,
56
+ reason:
57
+ `${GATE_ID}: ${path} is production source and ${seen}. Write the next failing test first, run it, ` +
58
+ `and watch it fail (RED); then change the source. If this change needs no new failing test (${JUDGED_EXEMPTIONS}), ` +
59
+ `call devsys_record_departure with gate "${GATE_ID}", scope "${scope}", naming the exemption in "why"; one ` +
60
+ `departure covers the rest of the ${scope}.`,
61
+ };
62
+ });
63
+ }
@@ -11,9 +11,11 @@ import {
11
11
  type JevStatus,
12
12
  type ParseError,
13
13
  type Phase,
14
+ type Profile,
14
15
  parseError,
15
16
  type Sizing,
16
17
  type SliceRef,
18
+ type TestRun,
17
19
  } from "../core/types.ts";
18
20
 
19
21
  export const STATE_ENTRY_TYPE = "devsys-state";
@@ -26,6 +28,7 @@ const PHASES: ReadonlyArray<Phase> = [
26
28
  "delivering",
27
29
  "idle",
28
30
  ];
31
+ const PROFILE_NAMES: ReadonlyArray<Profile> = ["rust", "typescript"];
29
32
  const SIZINGS: ReadonlyArray<Sizing> = ["fix", "change", "capability", "product"];
30
33
  const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
31
34
 
@@ -42,10 +45,31 @@ function parseCi(input: unknown): CiState | ParseError {
42
45
  return { status: status as CiStatusName, ...(sha !== undefined ? { sha } : {}) };
43
46
  }
44
47
 
48
+ function parseProfileList(input: unknown): Profile[] | ParseError {
49
+ if (!Array.isArray(input)) return parseError("profiles must be an array");
50
+ const out: Profile[] = [];
51
+ for (const name of input) {
52
+ if (!PROFILE_NAMES.includes(name as Profile))
53
+ return parseError(`unknown profile: ${String(name)}`);
54
+ out.push(name as Profile);
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function parseTestRun(input: unknown): TestRun | ParseError {
60
+ if (!isRecord(input)) return parseError("lastTestRun must be an object");
61
+ const { at, exitCode, summary } = input;
62
+ if (typeof at !== "string" || typeof exitCode !== "number" || typeof summary !== "string") {
63
+ return parseError("lastTestRun needs string at, number exitCode and string summary");
64
+ }
65
+ return { at, exitCode, summary };
66
+ }
67
+
45
68
  /** Boundary parse for persisted state: the only place state is cast from `unknown`. */
46
69
  export function parseDevsysState(input: unknown): DevsysState | ParseError {
47
70
  if (!isRecord(input)) return parseError("devsys state must be an object");
48
- const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt, ci } = input;
71
+ const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt, ci, profiles, lastTestRun } =
72
+ input;
49
73
  if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
50
74
  if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
51
75
  if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
@@ -66,6 +90,10 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
66
90
  }
67
91
  const parsedCi = ci === undefined ? undefined : parseCi(ci);
68
92
  if (isParseError(parsedCi)) return parsedCi;
93
+ const parsedProfiles = profiles === undefined ? undefined : parseProfileList(profiles);
94
+ if (isParseError(parsedProfiles)) return parsedProfiles;
95
+ const parsedRun = lastTestRun === undefined ? undefined : parseTestRun(lastTestRun);
96
+ if (isParseError(parsedRun)) return parsedRun;
69
97
  return {
70
98
  phase: phase as Phase,
71
99
  jev: jev as JevStatus,
@@ -74,6 +102,8 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
74
102
  ...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
75
103
  ...(lastPushAt !== undefined ? { lastPushAt } : {}),
76
104
  ...(parsedCi !== undefined ? { ci: parsedCi } : {}),
105
+ ...(parsedProfiles !== undefined ? { profiles: parsedProfiles } : {}),
106
+ ...(parsedRun !== undefined ? { lastTestRun: parsedRun } : {}),
77
107
  };
78
108
  }
79
109
 
@@ -0,0 +1,29 @@
1
+ import { type ExtensionAPI, isBashToolResult } from "@earendil-works/pi-coding-agent";
2
+ import { exitCodeOf, isTestRunnerCommand, summarizeOutput } from "../core/test-runner.ts";
3
+ import type { SessionState } from "./session-state.ts";
4
+
5
+ export type TestEvidenceDeps = { pi: ExtensionAPI; state: SessionState; now?: () => Date };
6
+
7
+ /**
8
+ * Records the outcome of every test-runner command in `lastTestRun`, so gates can ask "has this
9
+ * session seen a failing test?" without trusting the model's say-so.
10
+ */
11
+ export function registerTestEvidence(deps: TestEvidenceDeps): void {
12
+ const now = deps.now ?? (() => new Date());
13
+ deps.pi.on("tool_result", (event) => {
14
+ if (!isBashToolResult(event)) return;
15
+ const { command } = event.input;
16
+ if (typeof command !== "string" || !isTestRunnerCommand(command)) return;
17
+ const text = event.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("\n");
18
+ const exitCode = exitCodeOf({
19
+ isError: event.isError,
20
+ text,
21
+ structured: event.structuredContent,
22
+ command,
23
+ });
24
+ deps.state.update((s) => ({
25
+ ...s,
26
+ lastTestRun: { at: now().toISOString(), exitCode, summary: summarizeOutput(text) },
27
+ }));
28
+ });
29
+ }