@jwilger/pi-development-system 0.3.5 → 0.3.7

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.3.5",
3
+ "version": "0.3.7",
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"
@@ -1,4 +1,4 @@
1
- import { parse } from "shell-quote";
1
+ import { parseShell } from "./shell-parse.ts";
2
2
 
3
3
  export type GitIntent =
4
4
  | "history-rewrite"
@@ -58,7 +58,7 @@ const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("("
58
58
  /** Tokens of one line grouped into simple commands (operators separate segments). */
59
59
  function segments(line: string): string[][] {
60
60
  const result: string[][] = [[]];
61
- for (const token of parse(line, (name) => `$${name}`)) {
61
+ for (const token of parseShell(line, (name) => `$${name}`)) {
62
62
  if (typeof token === "string") result[result.length - 1]?.push(token);
63
63
  else if ("pattern" in token) result[result.length - 1]?.push(token.pattern);
64
64
  else if ("op" in token && isRedirect(token.op)) continue;
@@ -0,0 +1,21 @@
1
+ import { type ParseEntry, parse } from "shell-quote";
2
+
3
+ /**
4
+ * shell-quote throws on text that is valid shell or valid file content (a template literal inside a
5
+ * heredoc), and a guard must never crash the tool call. Retry with the `${` openers neutralised, then
6
+ * fall back to whitespace-split words so command classification still sees every token.
7
+ */
8
+ export function parseShell(line: string, env?: (name: string) => string): ParseEntry[] {
9
+ const attempt = (text: string): ParseEntry[] | undefined => {
10
+ try {
11
+ return env === undefined ? parse(text) : parse(text, env);
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ };
16
+ return (
17
+ attempt(line) ??
18
+ attempt(line.replaceAll("${", "$")) ??
19
+ line.split(/\s+/).filter((word) => word !== "")
20
+ );
21
+ }
@@ -1,5 +1,5 @@
1
1
  import { posix } from "node:path";
2
- import { parse } from "shell-quote";
2
+ import { parseShell } from "./shell-parse.ts";
3
3
 
4
4
  export type WeakeningSignals = { addsSkip: boolean; emptied: boolean; commentedOut: boolean };
5
5
 
@@ -151,7 +151,7 @@ function splitLines(command: string): string {
151
151
 
152
152
  /** Boundary decode of shell-quote's output into domain tokens; globs count as words. */
153
153
  function tokenize(command: string): Token[] {
154
- return parse(splitLines(command)).flatMap((entry): Token[] => {
154
+ return parseShell(splitLines(command)).flatMap((entry): Token[] => {
155
155
  if (typeof entry === "string") return [{ kind: "word", value: entry }];
156
156
  if ("pattern" in entry) return [{ kind: "word", value: entry.pattern }];
157
157
  if ("op" in entry) return [{ kind: "op", op: entry.op }];
@@ -70,6 +70,35 @@ const RISKY_ADDITION = /\/\*|<!--|=begin\b|"""|'''|\breturn\b|\bthrow\b|\bexit\b
70
70
 
71
71
  const GLOB = /[*?[\]{]/;
72
72
 
73
+ const NO_SIGNALS: WeakeningSignals = { addsSkip: false, emptied: false, commentedOut: false };
74
+
75
+ /** Adding lines never weakens a test unless an added line itself disables something. */
76
+ const isHarmlessAddition = (before: string, after: string, signals: WeakeningSignals): boolean =>
77
+ !signals.addsSkip &&
78
+ !signals.commentedOut &&
79
+ isPureAddition(before, after) &&
80
+ !addedLines(before, after).some((line) => RISKY_ADDITION.test(line));
81
+
82
+ /** Jev's reading of a change, or `undefined` when it sees nothing to act on. */
83
+ function jevVerdict(
84
+ judged: { weakens: number; motive: string },
85
+ deterministicSignal: boolean,
86
+ ): Verdict | undefined {
87
+ const weakening = judged.weakens >= WEAKENS_THRESHOLD;
88
+ if ((weakening || deterministicSignal) && judged.motive === "gate-gaming") {
89
+ return {
90
+ kind: "hard-stop",
91
+ why: "Jev reads this change as weakening a check to get a green result",
92
+ };
93
+ }
94
+ return weakening
95
+ ? {
96
+ kind: "require-departure",
97
+ why: `Jev reads this change as weakening the tests (motive: ${judged.motive})`,
98
+ }
99
+ : undefined;
100
+ }
101
+
73
102
  /**
74
103
  * Soft gate `tests.weaken`: deleting, skipping, emptying or loosening tests needs a recorded
75
104
  * departure; a change Jev reads as gaming a failing gate escalates to a hard stop.
@@ -90,10 +119,13 @@ export function registerTestGuard(deps: TestGuardDeps): void {
90
119
 
91
120
  const consumeOpenDeparture = (): void => {
92
121
  const { openDepartures, activeSlice } = deps.state.get();
93
- const used = openDepartures.find(
122
+ const applicable = openDepartures.filter(
94
123
  (d) => d.gate === gate && (d.scope.kind !== "slice" || d.scope.slice === activeSlice),
95
124
  );
96
- if (used?.scope.kind !== "once") return;
125
+ // A broader departure covers the call without being spent; only a lone once-scoped one is used up.
126
+ if (applicable.some((d) => d.scope.kind !== "once")) return;
127
+ const used = applicable[0];
128
+ if (used === undefined) return;
97
129
  deps.state.update((s) => ({
98
130
  ...s,
99
131
  openDepartures: s.openDepartures.filter((d) => d.id !== used.id),
@@ -103,41 +135,16 @@ export function registerTestGuard(deps: TestGuardDeps): void {
103
135
  const judge = async (change: Change, ctx: ExtensionContext): Promise<Verdict> => {
104
136
  const deleted = change.after === undefined && change.rewritten === undefined;
105
137
  const after = change.after ?? change.rewritten ?? "";
106
- const signals = deleted
107
- ? { addsSkip: false, emptied: false, commentedOut: false }
108
- : weakeningSignals(change.before, after);
109
- // Adding lines never weakens a test unless the added line itself is a skip marker.
110
- if (
111
- !deleted &&
112
- !signals.addsSkip &&
113
- !signals.commentedOut &&
114
- isPureAddition(change.before, after) &&
115
- !addedLines(change.before, after).some((line) => RISKY_ADDITION.test(line))
116
- ) {
117
- return { kind: "allow" };
118
- }
138
+ const signals = deleted ? NO_SIGNALS : weakeningSignals(change.before, after);
139
+ if (!deleted && isHarmlessAddition(change.before, after, signals)) return { kind: "allow" };
119
140
  const deterministic = describeSignals(deleted, signals);
120
141
  const judged = await judgeTestChange(deps.jev(ctx), {
121
142
  path: change.path,
122
143
  before: change.before,
123
144
  after: after === "" ? undefined : redactSecrets(after),
124
145
  });
125
- if (judged.ok) {
126
- const { weakens, motive } = judged.value;
127
- const weakening = weakens >= WEAKENS_THRESHOLD || deterministic !== undefined;
128
- if (weakening && motive === "gate-gaming") {
129
- return {
130
- kind: "hard-stop",
131
- why: "Jev reads this change as weakening a check to get a green result",
132
- };
133
- }
134
- if (weakens >= WEAKENS_THRESHOLD) {
135
- return {
136
- kind: "require-departure",
137
- why: `Jev reads this change as weakening the tests (motive: ${motive})`,
138
- };
139
- }
140
- }
146
+ const fromJev = judged.ok ? jevVerdict(judged.value, deterministic !== undefined) : undefined;
147
+ if (fromJev !== undefined) return fromJev;
141
148
  return deterministic !== undefined
142
149
  ? { kind: "require-departure", why: `this change ${deterministic}` }
143
150
  : { kind: "allow" };