@jwilger/pi-development-system 0.10.1 → 0.10.3
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
|
@@ -39,9 +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.
|
|
43
|
-
|
|
44
|
-
|
|
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):
|
|
45
45
|
|
|
46
46
|
- docs or metadata only
|
|
47
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: () =>
|
|
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
|
-
|
|
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
|
|
package/src/core/path-class.ts
CHANGED
|
@@ -8,6 +8,7 @@ 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;
|
|
12
13
|
// Build output directories only count at the repo root; `src/build/` is usually real source.
|
|
13
14
|
const GENERATED_DIR =
|
|
@@ -21,12 +22,14 @@ const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1)
|
|
|
21
22
|
export function classifyPath(input: string, profile?: TestPathProfile): PathClass {
|
|
22
23
|
const path = input.replace(/^\.\//, "");
|
|
23
24
|
const name = basename(path);
|
|
25
|
+
if (path.startsWith("../") || path.startsWith("/")) return "other"; // outside the repo
|
|
24
26
|
if (GENERATED_DIR.test(path) || GENERATED_FILE.test(path)) return "generated";
|
|
25
27
|
if (isTestPath(path, profile) && SOURCE_EXT.test(path)) return "test";
|
|
26
28
|
if (DOCS_EXT.test(name) || DOCS_NAME.test(name) || /^docs\//.test(path)) return "docs";
|
|
27
29
|
if (
|
|
28
30
|
CONFIG_EXT.test(name) ||
|
|
29
31
|
CONFIG_NAME.test(name) ||
|
|
32
|
+
TOOL_CONFIG.test(path) ||
|
|
30
33
|
name.startsWith(".") ||
|
|
31
34
|
/^\.github\//.test(path)
|
|
32
35
|
) {
|
package/src/core/test-paths.ts
CHANGED
|
@@ -14,6 +14,8 @@ const PATTERNS: readonly RegExp[] = [
|
|
|
14
14
|
/_test\.(go|rb|py|exs?|rs)$/,
|
|
15
15
|
/(^|\/)test_[^/]+\.py$/,
|
|
16
16
|
/_spec\.rb$/,
|
|
17
|
+
/(^|\/)tests?\.rs$/,
|
|
18
|
+
/_tests\.rs$/,
|
|
17
19
|
];
|
|
18
20
|
|
|
19
21
|
/** Glob match without building a RegExp: `**` spans directories, `*` stays within a segment. */
|
package/src/core/test-runner.ts
CHANGED
|
@@ -27,6 +27,8 @@ function isRunner(words: readonly string[]): boolean {
|
|
|
27
27
|
if (head === undefined) return false;
|
|
28
28
|
const args = rest.filter((w) => !w.startsWith("+"));
|
|
29
29
|
const first = args[0];
|
|
30
|
+
// Compile-only and listing runs prove nothing about RED/GREEN, so they must not overwrite a real result.
|
|
31
|
+
if (rest.some((w) => /^--(?:no-run|list|help)$/.test(w) || w === "-h")) return false;
|
|
30
32
|
if (head === "cargo") return first === "test" || first === "nextest";
|
|
31
33
|
if (head === "go" || head === "deno") return first === "test";
|
|
32
34
|
if (head === "node") return rest.includes("--test");
|
|
@@ -59,7 +61,7 @@ export type ResultLike = {
|
|
|
59
61
|
/** A pipe or sequence makes the shell report the last command's status, not the runner's. */
|
|
60
62
|
const MASKS_STATUS = /\||;/;
|
|
61
63
|
const FAILURE_MARKERS =
|
|
62
|
-
|
|
64
|
+
/^(?:# |ℹ )fail [1-9]|test result: FAILED|\b[1-9]\d* (?:failed|failing)\b|^FAILED\b|^FAIL\b|\bnot ok\b/m;
|
|
63
65
|
|
|
64
66
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
65
67
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -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";
|
|
@@ -11,11 +13,26 @@ export type RedFirstGuardDeps = { pi: ExtensionAPI; state: SessionState };
|
|
|
11
13
|
const GATE_ID = "tdd.red-first";
|
|
12
14
|
|
|
13
15
|
/** 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)\]
|
|
16
|
+
const INLINE_TEST = /#\[(?:cfg\(test\)|test|tokio::test|rstest)\]/g;
|
|
15
17
|
|
|
16
|
-
const
|
|
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 = (
|
|
17
30
|
input: { content: string } | { edits: ReadonlyArray<{ newText: string }> },
|
|
18
|
-
|
|
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;
|
|
19
36
|
|
|
20
37
|
/** Exemption classes a machine cannot see; the departure names which one applies (research 02). */
|
|
21
38
|
const JUDGED_EXEMPTIONS =
|
|
@@ -41,7 +58,7 @@ export function registerRedFirstGuard(deps: RedFirstGuardDeps): void {
|
|
|
41
58
|
if (lastTestRun !== undefined && lastTestRun.exitCode !== 0) return undefined;
|
|
42
59
|
const path = normalizeRepoPath(ctx.cwd, event.input.path, homedir());
|
|
43
60
|
if (classifyPath(path) !== "source") return undefined;
|
|
44
|
-
if (
|
|
61
|
+
if (addsInlineTest(event.input, () => existing(ctx.cwd, path))) return undefined;
|
|
45
62
|
if (departure.hasOpen()) {
|
|
46
63
|
departure.consume();
|
|
47
64
|
return undefined;
|