@jwilger/pi-development-system 0.10.0 → 0.10.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilger/pi-development-system",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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"
@@ -39,8 +39,9 @@ This skill is how to work so that gate never fires.
39
39
  ## When RED is not required
40
40
 
41
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, and the exemption
43
- must be *named* in a recorded departure (scope `slice`; one departure covers the slice):
42
+ failing test already proves the required change. Docs, config, test and generated files are exempt by path, so the gate never asks. For
43
+ any other exemption below, *name* it in a recorded departure (scope `slice`; one
44
+ departure covers the slice):
44
45
 
45
46
  - docs or metadata only
46
47
  - functionality removal (the proof is that nothing still depends on it)
@@ -31,7 +31,7 @@ const KINDS: readonly Kind[] = [
31
31
  {
32
32
  marker: "#![allow(",
33
33
  pattern: /#!\[allow\(/,
34
- inline: () => undefined,
34
+ inline: (line) => lastGroup(/reason\s*=\s*"([^"]*)"/, line),
35
35
  },
36
36
  {
37
37
  marker: "#[expect(",
@@ -66,7 +66,8 @@ const KINDS: readonly Kind[] = [
66
66
  },
67
67
  ];
68
68
 
69
- const COMMENT_LINE = /^\s*(?:\/\/+|\/\*+|\*|#+)\s*(.*?)(?:\s*\*\/)?\s*$/;
69
+ // `#` opens a comment in shell/Python, but `#[` / `#!` start Rust attributes and shebangs.
70
+ const COMMENT_LINE = /^\s*(?:\/\/+|\/\*+|\*|#+(?![[!]))\s*(.*?)(?:\s*\*\/)?\s*$/;
70
71
 
71
72
  const isSuppressionLine = (line: string): boolean => KINDS.some((k) => k.pattern.test(line));
72
73
 
@@ -85,8 +86,17 @@ function kindOf(line: string): Kind | undefined {
85
86
  return matched.find((k) => k.marker === "#![allow(") ?? matched[0];
86
87
  }
87
88
 
89
+ /** A rustfmt-wrapped `#[allow(\n lint,\n reason = "...",\n)]`: the reason sits on a later line. */
90
+ function wrappedReason(lines: readonly string[], index: number): boolean {
91
+ const attr = lines.slice(index, index + 8);
92
+ const end = attr.findIndex((l) => l.includes(")]"));
93
+ const text = (end < 0 ? attr : attr.slice(0, end + 1)).join(" ");
94
+ return long(lastGroup(/reason\s*=\s*"([^"]*)"/, text));
95
+ }
96
+
88
97
  function reasonOf(kind: Kind, lines: readonly string[], index: number): boolean {
89
98
  const line = lines[index] ?? "";
99
+ if (/^\s*#!?\[(?:allow|expect)\(\s*$/.test(line) && wrappedReason(lines, index)) return true;
90
100
  const match = kind.pattern.exec(line);
91
101
  const after = match === null ? "" : line.slice(match.index + match[0].length);
92
102
  return (
@@ -8,9 +8,11 @@ const SOURCE_EXT =
8
8
  const DOCS_EXT = /\.(?:md|mdx|rst|txt|adoc)$/i;
9
9
  const DOCS_NAME = /^(?:LICENSE|LICENCE|CHANGELOG|NOTICE|AUTHORS|CONTRIBUTING)(?:\..*)?$/i;
10
10
  const CONFIG_EXT = /\.(?:json|jsonc|toml|ya?ml|ini|cfg|conf|env|properties)$/i;
11
+ const TOOL_CONFIG = /(?:^|\/)[\w.-]+\.config\.[cm]?[jt]s$|(?:^|\/)\.[\w-]+rc\.[cm]?[jt]s$/;
11
12
  const CONFIG_NAME = /^(?:Dockerfile|Makefile|justfile|Rakefile|Gemfile|Procfile)$/i;
13
+ // Build output directories only count at the repo root; `src/build/` is usually real source.
12
14
  const GENERATED_DIR =
13
- /(?:^|\/)(?:node_modules|dist|target|build|coverage|__generated__|generated|\.next|vendor)\//;
15
+ /(?:^|\/)(?:node_modules|__generated__|\.next)\/|^(?:dist|target|build|coverage|vendor|generated)\//;
14
16
  const GENERATED_FILE =
15
17
  /(?:\.generated\.|\.gen\.|\.min\.[cm]?js$|\.pb\.go$|\.snap$|(?:^|\/)(?:package-lock\.json|Cargo\.lock|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$|\.lock$)/;
16
18
 
@@ -20,12 +22,14 @@ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
20
22
  export function classifyPath(input: string, profile?: TestPathProfile): PathClass {
21
23
  const path = input.replace(/^\.\//, "");
22
24
  const name = basename(path);
25
+ if (path.startsWith("../") || path.startsWith("/")) return "other"; // outside the repo
23
26
  if (GENERATED_DIR.test(path) || GENERATED_FILE.test(path)) return "generated";
24
27
  if (isTestPath(path, profile) && SOURCE_EXT.test(path)) return "test";
25
28
  if (DOCS_EXT.test(name) || DOCS_NAME.test(name) || /^docs\//.test(path)) return "docs";
26
29
  if (
27
30
  CONFIG_EXT.test(name) ||
28
31
  CONFIG_NAME.test(name) ||
32
+ TOOL_CONFIG.test(path) ||
29
33
  name.startsWith(".") ||
30
34
  /^\.github\//.test(path)
31
35
  ) {
@@ -52,8 +52,15 @@ export type ResultLike = {
52
52
  readonly isError: boolean;
53
53
  readonly text: string;
54
54
  readonly structured?: unknown;
55
+ /** The command that ran; used to tell whether its exit status could have been masked. */
56
+ readonly command?: string;
55
57
  };
56
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
+
57
64
  const isRecord = (v: unknown): v is Record<string, unknown> =>
58
65
  typeof v === "object" && v !== null && !Array.isArray(v);
59
66
 
@@ -64,7 +71,10 @@ export function exitCodeOf(result: ResultLike): number {
64
71
  }
65
72
  const line = /Command exited with code (\d+)\s*$/.exec(result.text);
66
73
  if (line?.[1] !== undefined) return Number(line[1]);
67
- return result.isError ? 1 : 0;
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;
68
78
  }
69
79
 
70
80
  /** The last few non-empty output lines, redacted and bounded: what the runner said at the end. */
@@ -1,4 +1,6 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
2
4
  import { type ExtensionAPI, isToolCallEventType } from "@earendil-works/pi-coding-agent";
3
5
  import { classifyPath } from "../core/path-class.ts";
4
6
  import { normalizeRepoPath } from "../core/test-paths.ts";
@@ -10,6 +12,28 @@ export type RedFirstGuardDeps = { pi: ExtensionAPI; state: SessionState };
10
12
 
11
13
  const GATE_ID = "tdd.red-first";
12
14
 
15
+ /** Test code written inside a source file (Rust `#[cfg(test)]`/`#[test]`): that edit is the RED step itself. */
16
+ const INLINE_TEST = /#\[(?:cfg\(test\)|test|tokio::test|rstest)\]/g;
17
+
18
+ const countInlineTests = (text: string): number => text.match(INLINE_TEST)?.length ?? 0;
19
+
20
+ const existing = (cwd: string, path: string): string => {
21
+ try {
22
+ return readFileSync(resolve(cwd, path), "utf8");
23
+ } catch {
24
+ return "";
25
+ }
26
+ };
27
+
28
+ /** Does this edit add test code? A whole-file write counts only markers beyond those already on disk. */
29
+ const addsInlineTest = (
30
+ input: { content: string } | { edits: ReadonlyArray<{ newText: string }> },
31
+ before: () => string,
32
+ ): boolean =>
33
+ "content" in input
34
+ ? countInlineTests(input.content) > countInlineTests(before())
35
+ : countInlineTests(input.edits.map((e) => e.newText).join("\n")) > 0;
36
+
13
37
  /** Exemption classes a machine cannot see; the departure names which one applies (research 02). */
14
38
  const JUDGED_EXEMPTIONS =
15
39
  "functionality removal, documented third-party behaviour, a change already shown by a failing test, " +
@@ -34,6 +58,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
34
58
  if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
35
59
  const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
36
60
  if (classifyPath(path) !== "source") return undefined;
61
+ if (addsInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
37
62
  if (departure.hasOpen()) {
38
63
  departure.consume();
39
64
  return undefined;
@@ -19,6 +19,7 @@ export function registerTestEvidence(deps: TestEvidenceDeps): void {
19
19
  isError: event.isError,
20
20
  text,
21
21
  structured: event.structuredContent,
22
+ command,
22
23
  });
23
24
  deps.state.update((s) => ({
24
25
  ...s,