pi-warden 0.71.0 → 0.74.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/CHANGELOG.md +54 -0
- package/dist/adaptive.d.ts +3 -2
- package/dist/adaptive.js +4 -3
- package/dist/backend.d.ts +33 -14
- package/dist/backend.js +27 -14
- package/dist/config.d.ts +13 -8
- package/dist/config.js +20 -20
- package/dist/conscience.d.ts +1 -1
- package/dist/extension.d.ts +14 -3
- package/dist/extension.js +410 -64
- package/dist/guard.d.ts +2 -1
- package/dist/guard.js +4 -4
- package/dist/holds.d.ts +1 -1
- package/dist/holds.js +3 -2
- package/dist/host-compat.d.ts +2 -0
- package/dist/host-compat.js +132 -0
- package/dist/host-dirs.d.ts +10 -0
- package/dist/host-dirs.js +21 -0
- package/dist/index-cmd.d.ts +6 -5
- package/dist/index-cmd.js +16 -24
- package/dist/index.d.ts +12 -4
- package/dist/index.js +6 -2
- package/dist/init.js +31 -3
- package/dist/learning.d.ts +12 -11
- package/dist/learning.js +60 -38
- package/dist/loops.d.ts +2 -1
- package/dist/loops.js +3 -2
- package/dist/panel.d.ts +4 -1
- package/dist/panel.js +11 -6
- package/dist/prefs.d.ts +1 -1
- package/dist/prefs.js +3 -2
- package/dist/rules-audit.d.ts +208 -0
- package/dist/rules-audit.js +417 -0
- package/dist/rules-calibrate.d.ts +196 -0
- package/dist/rules-calibrate.js +474 -0
- package/dist/rules-lint.d.ts +2 -0
- package/dist/rules-lint.js +7 -2
- package/dist/rules-log.d.ts +6 -1
- package/dist/rules-log.js +9 -3
- package/dist/rules-report.d.ts +3 -0
- package/dist/rules-report.js +5 -0
- package/dist/rules.d.ts +72 -5
- package/dist/rules.js +116 -54
- package/dist/shape.js +8 -2
- package/dist/turn-rules.d.ts +86 -0
- package/dist/turn-rules.js +232 -0
- package/dist/widget-render.d.ts +23 -0
- package/dist/widget-render.js +82 -0
- package/dist/widget.d.ts +5 -22
- package/dist/widget.js +3 -80
- package/package.json +5 -3
package/dist/rules.d.ts
CHANGED
|
@@ -9,10 +9,12 @@ import type { RulesConfig } from "./config.js";
|
|
|
9
9
|
*/
|
|
10
10
|
/** How badly a rule matters; used only to order findings, never to decide whether a rule fires. */
|
|
11
11
|
export type RuleSeverity = "high" | "normal" | "low";
|
|
12
|
+
/** When a rule is judged: `edit` on each write or edit (the default), `turn` once at the end of a run against the whole diff. */
|
|
13
|
+
export type RuleWhen = "edit" | "turn";
|
|
12
14
|
export interface Rule {
|
|
13
15
|
id: string;
|
|
14
16
|
name: string;
|
|
15
|
-
/** Rule text under the heading, fences included, `paths:`/`threshold:`/`severity:` lines removed. */
|
|
17
|
+
/** Rule text under the heading, fences included, `paths:`/`threshold:`/`severity:`/`source:` lines removed. */
|
|
16
18
|
body: string;
|
|
17
19
|
/** Globs the rule applies to; empty means every file. */
|
|
18
20
|
paths: string[];
|
|
@@ -20,6 +22,13 @@ export interface Rule {
|
|
|
20
22
|
threshold?: number;
|
|
21
23
|
/** Ordering only; absent means `normal`. */
|
|
22
24
|
severity?: RuleSeverity;
|
|
25
|
+
/** The `source: <file>:<line>` header: the instruction line the rule's wording came from. */
|
|
26
|
+
sourceRef?: {
|
|
27
|
+
file: string;
|
|
28
|
+
line: number;
|
|
29
|
+
};
|
|
30
|
+
/** `turn` rules are judged once per run against the whole diff, never per edit; absent means `edit`. */
|
|
31
|
+
when?: RuleWhen;
|
|
23
32
|
/** Header lines that carried a bad value, reported by `/warden rules`; the line is dropped either way. */
|
|
24
33
|
headerWarnings?: string[];
|
|
25
34
|
/** Project-relative source file when the rule came from a resolved RuleSet. */
|
|
@@ -50,6 +59,11 @@ export type RulesSourceConfig = Pick<RulesConfig, "files" | "fallback">;
|
|
|
50
59
|
export declare const FALLBACK_FILES: string[];
|
|
51
60
|
/** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
|
|
52
61
|
export declare const MAX_RULES = 31;
|
|
62
|
+
export declare const EDIT_TEXT_LIMIT = 1500;
|
|
63
|
+
export declare const EDIT_CONTEXT_LINES = 20;
|
|
64
|
+
export declare const MAX_EDITS = 6;
|
|
65
|
+
export declare const RULE_BODY_LIMIT = 400;
|
|
66
|
+
export declare const STEER_BODY_LIMIT = 200;
|
|
53
67
|
/**
|
|
54
68
|
* A fallback document cut to `limit` characters while keeping its shape: every heading stays and each section keeps its
|
|
55
69
|
* head, so a rule stated in the last section of a very long AGENTS.md is still seen. Code inside fences is treated as text.
|
|
@@ -84,6 +98,10 @@ export declare class RuleStore {
|
|
|
84
98
|
}
|
|
85
99
|
/** Rules that apply to a path: unscoped rules plus those whose `paths` match. */
|
|
86
100
|
export declare function rulesFor(set: RuleSet, path: string): Rule[];
|
|
101
|
+
/** Rules asked about a write or edit: those that apply to the path without a `when: turn` header. */
|
|
102
|
+
export declare function editRulesFor(set: RuleSet, path: string): Rule[];
|
|
103
|
+
/** Rules judged once per run against the whole diff: those with a `when: turn` header. */
|
|
104
|
+
export declare function turnRulesFor(set: RuleSet): Rule[];
|
|
87
105
|
export declare function describeRuleSet(set: RuleSet | undefined): string;
|
|
88
106
|
/** Detailed local-only view for `/warden rules`; nothing here is sent to Jev. */
|
|
89
107
|
export declare function formatRuleSetDetails(set: RuleSet | undefined, tier: RulesTier, exclude?: readonly string[]): string;
|
|
@@ -107,10 +125,11 @@ export interface RulesTarget {
|
|
|
107
125
|
/** The redacted content of a write or edit as Jev sees it, or undefined when the call carries nothing to judge. */
|
|
108
126
|
export declare function describeTarget(tool: string, input: Record<string, unknown>, cwd: string): RulesTarget | undefined;
|
|
109
127
|
export type RuleOutcome = "compliant" | "violation" | "not_applicable" | "insufficient_context";
|
|
128
|
+
export declare const OUTCOMES: Record<RuleOutcome, string>;
|
|
110
129
|
export declare const AGGREGATE_QUESTION = "rules";
|
|
111
130
|
export declare const LOCATOR_QUESTION = "which_edit";
|
|
112
131
|
export declare function ruleQuestion(rule: Rule): import("pi-typesafe").ChoiceQuestion<Record<RuleOutcome, string>>;
|
|
113
|
-
/** The rules asked about for a write: scoped to its path first, then cut to the request cap in file order. */
|
|
132
|
+
/** The rules asked about for a write: scoped to its path first, `when: turn` rules excluded, then cut to the request cap in file order. */
|
|
114
133
|
export declare function buildRulesRequest(target: RulesTarget, set: RuleSet): {
|
|
115
134
|
firstDropped?: string;
|
|
116
135
|
state: {
|
|
@@ -138,14 +157,38 @@ export interface RuleScore {
|
|
|
138
157
|
threshold?: number;
|
|
139
158
|
/** The rule's severity when it set one; absent means `normal`. */
|
|
140
159
|
severity?: RuleSeverity;
|
|
160
|
+
/** The instruction line the rule's wording came from when its `source:` header set one. */
|
|
161
|
+
sourceRef?: {
|
|
162
|
+
file: string;
|
|
163
|
+
line: number;
|
|
164
|
+
};
|
|
141
165
|
}
|
|
142
166
|
export interface RuleFinding extends RuleScore {
|
|
143
167
|
body: string;
|
|
144
168
|
}
|
|
169
|
+
/** One answer of the judge as the scoring reads it: the choice it picked and the probabilities behind it. */
|
|
170
|
+
export type RuleAnswer = {
|
|
171
|
+
type: string;
|
|
172
|
+
choice?: string;
|
|
173
|
+
probabilities?: Record<string, number>;
|
|
174
|
+
} | undefined;
|
|
175
|
+
export interface RuleScoring {
|
|
176
|
+
scores: RuleScore[];
|
|
177
|
+
/** Violations at or above the rule's cutoff, severity first then strongest. */
|
|
178
|
+
findings: RuleFinding[];
|
|
179
|
+
/** Scores at or above `rules.softThreshold` but below the rule's cutoff. */
|
|
180
|
+
softFindings: RuleFinding[];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Score the judge's answers rule by rule: one score each, a finding at the rule's own cutoff (or `rules.threshold` when
|
|
184
|
+
* the rule sets none), and a soft finding between `rules.softThreshold` and that cutoff. An aggregate document is one
|
|
185
|
+
* rule under its source's name. Shared by the live guard and history replays, so both respect the same cutoffs.
|
|
186
|
+
*/
|
|
187
|
+
export declare function scoreRuleAnswers(applicable: readonly Rule[], answers: Record<string, RuleAnswer>, config: RulesConfig, aggregateName?: string): RuleScoring;
|
|
145
188
|
export interface RulesVerdict {
|
|
146
189
|
source: "skipped" | "typesafe" | "error";
|
|
147
190
|
path: string;
|
|
148
|
-
tool: "write" | "edit";
|
|
191
|
+
tool: "write" | "edit" | "turn";
|
|
149
192
|
sources: string[];
|
|
150
193
|
/** Rules asked about, after path scoping and the cap; 1 for an aggregate document. */
|
|
151
194
|
asked: number;
|
|
@@ -175,11 +218,35 @@ export interface RulesOptions {
|
|
|
175
218
|
timeoutMs: number;
|
|
176
219
|
signal?: AbortSignal | undefined;
|
|
177
220
|
}
|
|
178
|
-
/** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd
|
|
179
|
-
export declare function gitIgnored(projectRel: string, cwd: string): boolean;
|
|
221
|
+
/** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd` (with `--no-index`, so a force-added ignored file still counts as ignored). Returns false when git is unavailable or the path cannot be checked. */
|
|
222
|
+
export declare function gitIgnored(projectRel: string, cwd: string, noIndex?: boolean): boolean;
|
|
180
223
|
/** Why a write or edit is not judged against the rules: nothing to judge, no rules, or a path the config keeps out. */
|
|
181
224
|
export declare function skipReason(target: RulesTarget | undefined, set: RuleSet | undefined, config: RulesConfig): string | undefined;
|
|
225
|
+
/** One request's answer payload as Jev returns it. */
|
|
226
|
+
/** One question in a rules request: its key, the rule it came from, and the body quoted in a steer. */
|
|
227
|
+
export interface RuleScoreEntry {
|
|
228
|
+
key: string;
|
|
229
|
+
id: string;
|
|
230
|
+
name: string;
|
|
231
|
+
body: string;
|
|
232
|
+
/** The rule's own cutoff, severity, and source citation when it set them. */
|
|
233
|
+
rule?: Pick<Rule, "threshold" | "severity" | "sourceRef">;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Scores and findings for one request's answers: a finding needs the rule's own cutoff when it set one, else the global
|
|
237
|
+
* threshold, and the soft tier sits under that cutoff. Findings are ordered severity first, then the strongest score.
|
|
238
|
+
*/
|
|
239
|
+
export declare function scoreAnswers(answers: Record<string, RuleAnswer | undefined>, entries: readonly RuleScoreEntry[], config: RulesConfig): {
|
|
240
|
+
scores: RuleScore[];
|
|
241
|
+
findings: RuleFinding[];
|
|
242
|
+
softFindings: RuleFinding[];
|
|
243
|
+
};
|
|
182
244
|
export declare function evaluateRules(tool: string, input: Record<string, unknown>, options: RulesOptions): Promise<RulesVerdict>;
|
|
245
|
+
/**
|
|
246
|
+
* The judgment half of `evaluateRules` for a prepared target: skip reasons, one request, scores, findings. A caller that
|
|
247
|
+
* builds its own target (the end-of-run pass judges a file's diff as one edit) gets the same question and scoring.
|
|
248
|
+
*/
|
|
249
|
+
export declare function evaluateRulesTarget(target: RulesTarget | undefined, tool: string, shownPath: string, options: RulesOptions): Promise<RulesVerdict>;
|
|
183
250
|
/** Names each violated rule with a short quote of its text; the third hit of one rule in a session makes it a standing rule. */
|
|
184
251
|
export declare function rulesSteer(verdict: RulesVerdict, counts: ReadonlyMap<string, number>): string;
|
|
185
252
|
export declare function formatRules(verdict: RulesVerdict, template?: string): string;
|
package/dist/rules.js
CHANGED
|
@@ -12,11 +12,11 @@ export const FALLBACK_FILES = ["AGENTS.md", "CLAUDE.md", "README.md"];
|
|
|
12
12
|
/** TypeSafe answers at most 32 questions per request; one is kept for the edit locator. */
|
|
13
13
|
export const MAX_RULES = 31;
|
|
14
14
|
const CONTENT_LIMIT = 6000;
|
|
15
|
-
const EDIT_TEXT_LIMIT = 1500;
|
|
16
|
-
const EDIT_CONTEXT_LINES = 20;
|
|
17
|
-
const MAX_EDITS = 6;
|
|
18
|
-
const RULE_BODY_LIMIT = 400;
|
|
19
|
-
const STEER_BODY_LIMIT = 200;
|
|
15
|
+
export const EDIT_TEXT_LIMIT = 1500;
|
|
16
|
+
export const EDIT_CONTEXT_LINES = 20;
|
|
17
|
+
export const MAX_EDITS = 6;
|
|
18
|
+
export const RULE_BODY_LIMIT = 400;
|
|
19
|
+
export const STEER_BODY_LIMIT = 200;
|
|
20
20
|
const ID_LIMIT = 64;
|
|
21
21
|
const RULE_SHAPE_CONTEXT_LINES = 15;
|
|
22
22
|
const MODAL_WORDS = /^(?:must|shall|never|always|do not|don't|cannot|can't|avoid|prefer|require|should not|shouldn't|no |not )/i;
|
|
@@ -28,7 +28,10 @@ const HEADING = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
|
|
|
28
28
|
const PATHS_LINE = /^\s*(?:paths?|applies to|files?)\s*:\s*(.+?)\s*$/i;
|
|
29
29
|
const THRESHOLD_LINE = /^\s*threshold\s*:\s*(.+?)\s*$/i;
|
|
30
30
|
const SEVERITY_LINE = /^\s*severity\s*:\s*(.+?)\s*$/i;
|
|
31
|
+
const SOURCE_REF_LINE = /^\s*source\s*:\s*(.+?)\s*$/i;
|
|
32
|
+
const WHEN_LINE = /^\s*when\s*:\s*(.+?)\s*$/i;
|
|
31
33
|
const SEVERITIES = ["high", "normal", "low"];
|
|
34
|
+
const WHENS = ["edit", "turn"];
|
|
32
35
|
function slug(name, used) {
|
|
33
36
|
const full = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
34
37
|
// Cut at a word boundary so a long heading keeps whole words in its id.
|
|
@@ -109,10 +112,14 @@ export function parseRules(markdown) {
|
|
|
109
112
|
let paths = [];
|
|
110
113
|
let threshold;
|
|
111
114
|
let severity;
|
|
115
|
+
let sourceRef;
|
|
116
|
+
let when;
|
|
112
117
|
const headerWarnings = [];
|
|
113
118
|
let seenPaths = false;
|
|
114
119
|
let seenThreshold = false;
|
|
115
120
|
let seenSeverity = false;
|
|
121
|
+
let seenSource = false;
|
|
122
|
+
let seenWhen = false;
|
|
116
123
|
// Header lines may sit in any order and at most once. A bad value is dropped with a warning; the line never stays in the body.
|
|
117
124
|
for (;;) {
|
|
118
125
|
const first = lines.findIndex(text => text.trim());
|
|
@@ -159,12 +166,46 @@ export function parseRules(markdown) {
|
|
|
159
166
|
}
|
|
160
167
|
continue;
|
|
161
168
|
}
|
|
169
|
+
// One small block for the `source: <file>:<line>` citation header, beside the other header lines.
|
|
170
|
+
const cited = SOURCE_REF_LINE.exec(line);
|
|
171
|
+
if (cited) {
|
|
172
|
+
lines.splice(first, 1);
|
|
173
|
+
const raw = cited[1].replace(/^`|`$/g, "").trim();
|
|
174
|
+
const ref = /^(.+):(\d+)$/.exec(raw);
|
|
175
|
+
const file = ref?.[1]?.trim();
|
|
176
|
+
const at = ref ? Number(ref[2]) : 0;
|
|
177
|
+
if (seenSource)
|
|
178
|
+
headerWarnings.push("source: appears more than once; the first is kept");
|
|
179
|
+
else if (!file || !at)
|
|
180
|
+
headerWarnings.push(`source: ${raw || "(empty)"} is not a file:line reference; ignored`);
|
|
181
|
+
else {
|
|
182
|
+
sourceRef = { file, line: at };
|
|
183
|
+
seenSource = true;
|
|
184
|
+
}
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const timing = WHEN_LINE.exec(line);
|
|
188
|
+
if (timing) {
|
|
189
|
+
lines.splice(first, 1);
|
|
190
|
+
const raw = timing[1].trim().toLowerCase();
|
|
191
|
+
if (seenWhen)
|
|
192
|
+
headerWarnings.push("when: appears more than once; the first is kept");
|
|
193
|
+
else if (!WHENS.includes(raw))
|
|
194
|
+
headerWarnings.push(`when: ${timing[1].trim() || "(empty)"} is not edit or turn; ignored`);
|
|
195
|
+
else {
|
|
196
|
+
when = raw;
|
|
197
|
+
seenWhen = true;
|
|
198
|
+
}
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
162
201
|
break;
|
|
163
202
|
}
|
|
164
203
|
return {
|
|
165
204
|
id: slug(draft.name, used), name: draft.name.trim(), body: lines.join("\n").trim(), paths,
|
|
166
205
|
...(threshold === undefined ? {} : { threshold }),
|
|
167
206
|
...(severity === undefined ? {} : { severity }),
|
|
207
|
+
...(sourceRef === undefined ? {} : { sourceRef }),
|
|
208
|
+
...(when === undefined ? {} : { when }),
|
|
168
209
|
...(headerWarnings.length ? { headerWarnings } : {}),
|
|
169
210
|
};
|
|
170
211
|
});
|
|
@@ -321,6 +362,14 @@ function ruleSet(sources, texts, maxChars) {
|
|
|
321
362
|
export function rulesFor(set, path) {
|
|
322
363
|
return set.rules.filter(rule => !rule.paths.length || matchGlob(path, rule.paths) !== undefined);
|
|
323
364
|
}
|
|
365
|
+
/** Rules asked about a write or edit: those that apply to the path without a `when: turn` header. */
|
|
366
|
+
export function editRulesFor(set, path) {
|
|
367
|
+
return rulesFor(set, path).filter(rule => rule.when !== "turn");
|
|
368
|
+
}
|
|
369
|
+
/** Rules judged once per run against the whole diff: those with a `when: turn` header. */
|
|
370
|
+
export function turnRulesFor(set) {
|
|
371
|
+
return set.rules.filter(rule => rule.when === "turn");
|
|
372
|
+
}
|
|
324
373
|
export function describeRuleSet(set) {
|
|
325
374
|
if (!set)
|
|
326
375
|
return "none found";
|
|
@@ -350,6 +399,8 @@ export function formatRuleSetDetails(set, tier, exclude = []) {
|
|
|
350
399
|
`paths: ${rule.paths.length ? rule.paths.join(", ") : "(all)"}`,
|
|
351
400
|
...(rule.threshold === undefined ? [] : [`threshold: ${rule.threshold}`]),
|
|
352
401
|
...(rule.severity === undefined ? [] : [`severity: ${rule.severity}`]),
|
|
402
|
+
...(rule.sourceRef === undefined ? [] : [`source: ${rule.sourceRef.file}:${rule.sourceRef.line}`]),
|
|
403
|
+
...(rule.when === undefined || rule.when === "edit" ? [] : [`when: ${rule.when}`]),
|
|
353
404
|
].join(", ");
|
|
354
405
|
lines.push(`${index + 1}. ${rule.id}${from} ${settings}`);
|
|
355
406
|
if (rule.headerWarnings?.length)
|
|
@@ -431,7 +482,7 @@ export function describeTarget(tool, input, cwd) {
|
|
|
431
482
|
const more = input.edits.length - MAX_EDITS;
|
|
432
483
|
return { tool, path, edits, ...(more > 0 ? { moreEdits: more } : {}) };
|
|
433
484
|
}
|
|
434
|
-
const OUTCOMES = {
|
|
485
|
+
export const OUTCOMES = {
|
|
435
486
|
compliant: "The change follows this rule, or leaves an earlier violation as it was.",
|
|
436
487
|
violation: "The change introduces a violation of this rule.",
|
|
437
488
|
not_applicable: "This rule does not concern the kind of content written: another language, file type, or subject.",
|
|
@@ -443,9 +494,9 @@ export const LOCATOR_QUESTION = "which_edit";
|
|
|
443
494
|
export function ruleQuestion(rule) {
|
|
444
495
|
return choice(`${FRAME}\nRule: ${rule.name}\n${rule.body ? clip(rule.body, RULE_BODY_LIMIT) : "(no further detail beyond the heading)"}`, OUTCOMES);
|
|
445
496
|
}
|
|
446
|
-
/** The rules asked about for a write: scoped to its path first, then cut to the request cap in file order. */
|
|
497
|
+
/** The rules asked about for a write: scoped to its path first, `when: turn` rules excluded, then cut to the request cap in file order. */
|
|
447
498
|
export function buildRulesRequest(target, set) {
|
|
448
|
-
const scoped =
|
|
499
|
+
const scoped = editRulesFor(set, target.path);
|
|
449
500
|
const applicable = scoped.slice(0, MAX_RULES);
|
|
450
501
|
const dropped = scoped.length - applicable.length;
|
|
451
502
|
const questions = {};
|
|
@@ -476,10 +527,21 @@ export function buildRulesRequest(target, set) {
|
|
|
476
527
|
...(dropped ? { firstDropped: scoped[MAX_RULES].id } : {}),
|
|
477
528
|
};
|
|
478
529
|
}
|
|
479
|
-
/**
|
|
480
|
-
|
|
530
|
+
/**
|
|
531
|
+
* Score the judge's answers rule by rule: one score each, a finding at the rule's own cutoff (or `rules.threshold` when
|
|
532
|
+
* the rule sets none), and a soft finding between `rules.softThreshold` and that cutoff. An aggregate document is one
|
|
533
|
+
* rule under its source's name. Shared by the live guard and history replays, so both respect the same cutoffs.
|
|
534
|
+
*/
|
|
535
|
+
export function scoreRuleAnswers(applicable, answers, config, aggregateName) {
|
|
536
|
+
const entries = aggregateName !== undefined
|
|
537
|
+
? [{ key: AGGREGATE_QUESTION, id: AGGREGATE_QUESTION, name: aggregateName, body: "" }]
|
|
538
|
+
: applicable.map(rule => ({ key: `rule_${rule.id}`, id: rule.id, name: rule.name, body: rule.body, rule }));
|
|
539
|
+
return scoreAnswers(answers, entries, config);
|
|
540
|
+
}
|
|
541
|
+
/** Check whether a project-relative path is ignored by the project's gitignore rules. Uses `git check-ignore -q` run from `cwd` (with `--no-index`, so a force-added ignored file still counts as ignored). Returns false when git is unavailable or the path cannot be checked. */
|
|
542
|
+
export function gitIgnored(projectRel, cwd, noIndex = false) {
|
|
481
543
|
try {
|
|
482
|
-
execFileSync("git", ["check-ignore", "-q", projectRel], { cwd, timeout: 2000, stdio: "pipe" });
|
|
544
|
+
execFileSync("git", ["check-ignore", ...(noIndex ? ["--no-index"] : []), "-q", projectRel], { cwd, timeout: 2000, stdio: "pipe" });
|
|
483
545
|
return true;
|
|
484
546
|
}
|
|
485
547
|
catch {
|
|
@@ -500,16 +562,54 @@ export function skipReason(target, set, config) {
|
|
|
500
562
|
return `rules do not apply by rules.skip (${skipped})`;
|
|
501
563
|
if (set.proseOnly)
|
|
502
564
|
return `no rules found in ${set.sources.join(", ")} (prose only)`;
|
|
503
|
-
if (set.rules.length && !
|
|
565
|
+
if (set.rules.length && !editRulesFor(set, target.path).length)
|
|
504
566
|
return "no rule's paths match this file";
|
|
505
567
|
return undefined;
|
|
506
568
|
}
|
|
507
569
|
function skipped(target, tool, path, set, reason) {
|
|
508
570
|
return { source: "skipped", tool: target?.tool ?? (tool === "edit" ? "edit" : "write"), path, sources: set?.sources ?? [], asked: 0, aggregate: false, findings: [], skippedReason: reason };
|
|
509
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Scores and findings for one request's answers: a finding needs the rule's own cutoff when it set one, else the global
|
|
574
|
+
* threshold, and the soft tier sits under that cutoff. Findings are ordered severity first, then the strongest score.
|
|
575
|
+
*/
|
|
576
|
+
export function scoreAnswers(answers, entries, config) {
|
|
577
|
+
const scores = [];
|
|
578
|
+
const findings = [];
|
|
579
|
+
const softFindings = [];
|
|
580
|
+
const softThreshold = config.softThreshold ?? 0;
|
|
581
|
+
for (const entry of entries) {
|
|
582
|
+
const answer = answers[entry.key];
|
|
583
|
+
if (!answer || typeof answer.choice !== "string")
|
|
584
|
+
continue;
|
|
585
|
+
const violation = answer.probabilities?.violation ?? (answer.choice === "violation" ? 1 : 0);
|
|
586
|
+
const outcome = (answer.choice in OUTCOMES ? answer.choice : "insufficient_context");
|
|
587
|
+
const score = {
|
|
588
|
+
id: entry.id, name: entry.name, outcome, violation,
|
|
589
|
+
...(entry.rule?.threshold === undefined ? {} : { threshold: entry.rule.threshold }),
|
|
590
|
+
...(entry.rule?.severity === undefined ? {} : { severity: entry.rule.severity }),
|
|
591
|
+
...(entry.rule?.sourceRef === undefined ? {} : { sourceRef: entry.rule.sourceRef }),
|
|
592
|
+
};
|
|
593
|
+
const cutoff = entry.rule?.threshold ?? config.threshold;
|
|
594
|
+
scores.push(score);
|
|
595
|
+
if (score.violation >= cutoff)
|
|
596
|
+
findings.push({ ...score, body: entry.body });
|
|
597
|
+
else if (softThreshold > 0 && score.violation >= softThreshold)
|
|
598
|
+
softFindings.push({ ...score, body: entry.body });
|
|
599
|
+
}
|
|
600
|
+
findings.sort(bySeverityThenScore);
|
|
601
|
+
softFindings.sort(bySeverityThenScore);
|
|
602
|
+
return { scores, findings, softFindings };
|
|
603
|
+
}
|
|
510
604
|
export async function evaluateRules(tool, input, options) {
|
|
511
605
|
const target = describeTarget(tool, input, options.cwd);
|
|
512
|
-
|
|
606
|
+
return evaluateRulesTarget(target, tool, typeof input.path === "string" ? input.path : tool, options);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The judgment half of `evaluateRules` for a prepared target: skip reasons, one request, scores, findings. A caller that
|
|
610
|
+
* builds its own target (the end-of-run pass judges a file's diff as one edit) gets the same question and scoring.
|
|
611
|
+
*/
|
|
612
|
+
export async function evaluateRulesTarget(target, tool, shownPath, options) {
|
|
513
613
|
const { set } = options;
|
|
514
614
|
const reason = skipReason(target, set, options.config);
|
|
515
615
|
if (reason || !target || !set)
|
|
@@ -523,46 +623,7 @@ export async function evaluateRules(tool, input, options) {
|
|
|
523
623
|
if (!result.ok)
|
|
524
624
|
return { source: "error", ...base, findings: [], error: result.error, ...(result.errorCode ? { errorCode: result.errorCode } : {}) };
|
|
525
625
|
const answers = result.answers;
|
|
526
|
-
const
|
|
527
|
-
const answer = answers[key];
|
|
528
|
-
if (!answer || typeof answer.choice !== "string")
|
|
529
|
-
return undefined;
|
|
530
|
-
const violation = answer.probabilities?.violation ?? (answer.choice === "violation" ? 1 : 0);
|
|
531
|
-
const outcome = (answer.choice in OUTCOMES ? answer.choice : "insufficient_context");
|
|
532
|
-
return { id, name, outcome, violation };
|
|
533
|
-
};
|
|
534
|
-
const scores = [];
|
|
535
|
-
const findings = [];
|
|
536
|
-
const softFindings = [];
|
|
537
|
-
const softThreshold = options.config.softThreshold ?? 0;
|
|
538
|
-
// A finding needs the rule's own cutoff when it set one, else the global threshold. The soft tier sits under that cutoff.
|
|
539
|
-
const scoreRule = (score, rule, body) => {
|
|
540
|
-
const cutoff = rule?.threshold ?? options.config.threshold;
|
|
541
|
-
const scored = {
|
|
542
|
-
...score,
|
|
543
|
-
...(rule?.threshold === undefined ? {} : { threshold: rule.threshold }),
|
|
544
|
-
...(rule?.severity === undefined ? {} : { severity: rule.severity }),
|
|
545
|
-
};
|
|
546
|
-
scores.push(scored);
|
|
547
|
-
if (scored.violation >= cutoff)
|
|
548
|
-
findings.push({ ...scored, body });
|
|
549
|
-
else if (softThreshold > 0 && scored.violation >= softThreshold)
|
|
550
|
-
softFindings.push({ ...scored, body });
|
|
551
|
-
};
|
|
552
|
-
if (aggregate) {
|
|
553
|
-
const score = read(AGGREGATE_QUESTION, AGGREGATE_QUESTION, `the project's ${set.sources[0]}`);
|
|
554
|
-
if (score)
|
|
555
|
-
scoreRule(score, undefined, "");
|
|
556
|
-
}
|
|
557
|
-
else {
|
|
558
|
-
for (const rule of request.applicable) {
|
|
559
|
-
const score = read(`rule_${rule.id}`, rule.id, rule.name);
|
|
560
|
-
if (score)
|
|
561
|
-
scoreRule(score, rule, rule.body);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
findings.sort(bySeverityThenScore);
|
|
565
|
-
softFindings.sort(bySeverityThenScore);
|
|
626
|
+
const { scores, findings, softFindings } = scoreRuleAnswers(request.applicable, answers, options.config, aggregate ? `the project's ${set.sources[0]}` : undefined);
|
|
566
627
|
const verdict = { source: "typesafe", ...base, scores, findings, ...(softFindings.length ? { softFindings } : {}), model: result.model, elapsedMs: result.elapsedMs };
|
|
567
628
|
const locator = answers[LOCATOR_QUESTION];
|
|
568
629
|
if (findings.length && typeof locator?.choice === "string") {
|
|
@@ -590,7 +651,8 @@ export function rulesSteer(verdict, counts) {
|
|
|
590
651
|
const count = counts.get(finding.id) ?? 0;
|
|
591
652
|
const repeat = count >= 3 ? `; ${count}${count === 3 ? "rd" : "th"} time this session` : "";
|
|
592
653
|
const body = finding.body ? `: ${clip(finding.body.replace(/\s+/g, " ").trim(), STEER_BODY_LIMIT).replace(/[.;:,]+$/, "")}` : "";
|
|
593
|
-
|
|
654
|
+
const from = finding.sourceRef ? ` (from ${finding.sourceRef.file} line ${finding.sourceRef.line})` : "";
|
|
655
|
+
return `"${finding.name}"${from} (${finding.violation.toFixed(2)}${repeat})${body}`;
|
|
594
656
|
}).join("; ");
|
|
595
657
|
// The steer names the rule and the written file only: a named rules or config file sends a weak model off to read it.
|
|
596
658
|
const what = verdict.aggregate ? "breaks a project rule" : `violates project rule${verdict.findings.length === 1 ? "" : "s"}`;
|
package/dist/shape.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isMode } from "./config.js";
|
|
2
|
-
import {
|
|
2
|
+
import { resolveJudgmentBackend } from "./backend.js";
|
|
3
3
|
import { redact } from "./redact.js";
|
|
4
4
|
import { DEFAULT_TEMPLATES } from "./widget.js";
|
|
5
5
|
/** The config layout this extension build expects; compared with the loaded config module's CONFIG_SCHEMA. */
|
|
@@ -8,6 +8,10 @@ const off = { enabled: false };
|
|
|
8
8
|
// Duplicated from defaultConfig(): this file must work when the config module is stale and lacks the key.
|
|
9
9
|
const coreTools = () => ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
10
10
|
const proseOff = () => ({ ...off, audience: "technical", threshold: 1, trend: 3, minChars: 1 });
|
|
11
|
+
// A stale config module hands back either its own resolution or a raw value; both end up resolved here.
|
|
12
|
+
const resolveSetting = (source) => source.backendRefusal !== undefined
|
|
13
|
+
? { typesafeBackend: source.typesafeBackend, backendRefusal: source.backendRefusal }
|
|
14
|
+
: resolveJudgmentBackend(source.typesafeBackend);
|
|
11
15
|
/**
|
|
12
16
|
* `loadConfig()` always returns a complete object, yet live sessions crashed at `config.slop.prose.enabled` after a package
|
|
13
17
|
* update. A partially updated module graph (extension from one version, config from another) is the only known way to get
|
|
@@ -23,10 +27,12 @@ export function completeConfig(loaded) {
|
|
|
23
27
|
missing.push(key);
|
|
24
28
|
return fallback;
|
|
25
29
|
};
|
|
30
|
+
const backend = resolveSetting(source);
|
|
26
31
|
const config = {
|
|
27
32
|
enabled: source.enabled ?? true,
|
|
28
33
|
typesafe: source.typesafe ?? false,
|
|
29
|
-
typesafeBackend:
|
|
34
|
+
typesafeBackend: backend.typesafeBackend,
|
|
35
|
+
backendRefusal: backend.backendRefusal,
|
|
30
36
|
mode: isMode(source.mode) ? source.mode : "steer",
|
|
31
37
|
timeoutMs: source.timeoutMs ?? 5000,
|
|
32
38
|
maxRequests: source.maxRequests ?? 500,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { Judge } from "pi-typesafe";
|
|
2
|
+
import type { RulesConfig } from "./config.js";
|
|
3
|
+
import type { Rule, RulesVerdict, RuleSet } from "./rules.js";
|
|
4
|
+
import type { ShellSkip } from "./shell-writes.js";
|
|
5
|
+
/** The verdict path for a judgment about the whole run; the rules log keys clears by path, so it must not look like a file. */
|
|
6
|
+
export declare const TURN_PATH = "(whole turn)";
|
|
7
|
+
export declare const TURN_QUESTION_PREFIX = "turn_";
|
|
8
|
+
/** The files judged per shell command and per run; beyond the cap the files are named in the trace and left out. */
|
|
9
|
+
export declare const SHELL_RULES_CHECKS = 5;
|
|
10
|
+
export type SnapshotResult = {
|
|
11
|
+
tree: string;
|
|
12
|
+
reason?: undefined;
|
|
13
|
+
} | {
|
|
14
|
+
tree?: undefined;
|
|
15
|
+
reason: string;
|
|
16
|
+
};
|
|
17
|
+
/** The run's baseline; `reason` says why turn rules are skipped this run and earns one trace line in the caller. */
|
|
18
|
+
export declare function snapshotTree(cwd: string): Promise<SnapshotResult>;
|
|
19
|
+
export interface TurnFileDiff {
|
|
20
|
+
/** Project-relative path with forward slashes. */
|
|
21
|
+
path: string;
|
|
22
|
+
/** `M`, `A`, or `D`, as git reports it between the two trees. */
|
|
23
|
+
status: string;
|
|
24
|
+
/** This file's unified diff, capped at `maxChars` with an inline note when cut. */
|
|
25
|
+
diff: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TurnDiff {
|
|
28
|
+
files: TurnFileDiff[];
|
|
29
|
+
/** The whole change for the turn question: every file's diff joined, capped at `maxChars` in total. */
|
|
30
|
+
text: string;
|
|
31
|
+
/** What the caps cut, named per file, so the request and the trace say what the judge does not see. */
|
|
32
|
+
cuts: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The working-tree change since the snapshot: the files git reports and each one's unified diff, capped per file and
|
|
36
|
+
* in total at `maxChars` (the `rules.maxChars` cap), each cut named. Untracked non-ignored files are in both trees, so
|
|
37
|
+
* they appear here like any other change. Undefined when the end-of-run snapshot fails.
|
|
38
|
+
*/
|
|
39
|
+
export declare function diffSince(cwd: string, startTree: string, maxChars: number): Promise<TurnDiff | undefined>;
|
|
40
|
+
export declare function turnRuleQuestion(rule: Rule): import("pi-typesafe").ChoiceQuestion<Record<import("./rules.js").RuleOutcome, string>>;
|
|
41
|
+
export declare function buildTurnRequest(task: string, diff: string, rules: readonly Rule[], cuts?: readonly string[]): {
|
|
42
|
+
state: {
|
|
43
|
+
truncated?: string;
|
|
44
|
+
task: string;
|
|
45
|
+
diff: string;
|
|
46
|
+
};
|
|
47
|
+
questions: Record<string, import("pi-typesafe").ChoiceQuestion<import("@typesafe-ai/sdk").ChoiceCriteria>>;
|
|
48
|
+
};
|
|
49
|
+
export interface TurnRulesOptions {
|
|
50
|
+
judge: Judge;
|
|
51
|
+
config: RulesConfig;
|
|
52
|
+
timeoutMs: number;
|
|
53
|
+
signal?: AbortSignal | undefined;
|
|
54
|
+
sources: readonly string[];
|
|
55
|
+
}
|
|
56
|
+
/** One request that judges the turn rules against the whole diff and the task. */
|
|
57
|
+
export declare function evaluateTurnRules(task: string, diff: string, rules: readonly Rule[], options: TurnRulesOptions): Promise<RulesVerdict>;
|
|
58
|
+
export interface TurnRunOptions {
|
|
59
|
+
cwd: string;
|
|
60
|
+
config: RulesConfig;
|
|
61
|
+
set: RuleSet | undefined;
|
|
62
|
+
judge?: Judge | undefined;
|
|
63
|
+
timeoutMs: number;
|
|
64
|
+
signal?: AbortSignal | undefined;
|
|
65
|
+
/** The user's request for this run. */
|
|
66
|
+
task?: string | undefined;
|
|
67
|
+
/** The snapshot tree taken at the start of the run. */
|
|
68
|
+
startTree: string;
|
|
69
|
+
/** Project-relative paths a `write`, `edit`, or literal shell write already judged during this run. */
|
|
70
|
+
alreadyJudged: ReadonlySet<string>;
|
|
71
|
+
}
|
|
72
|
+
export interface TurnRunResult {
|
|
73
|
+
/** The turn verdict first (when turn rules exist), then one per file a per-edit judgment never saw. */
|
|
74
|
+
verdicts: RulesVerdict[];
|
|
75
|
+
/** What the caps cut from the diff, named per file. */
|
|
76
|
+
cuts: string[];
|
|
77
|
+
/** Files the per-run request cap left unjudged, named for the trace like the shell path's skips. */
|
|
78
|
+
skips: ShellSkip[];
|
|
79
|
+
/** Why nothing was judged, when the pass was skipped; absent when there was nothing to judge or it ran. */
|
|
80
|
+
skipped?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Which files of the diff no per-edit judgment saw: in-place and generated changes, and subagent writes. */
|
|
83
|
+
export declare function unjudgedFiles(diff: TurnDiff, alreadyJudged: ReadonlySet<string>): TurnFileDiff[];
|
|
84
|
+
export declare function evaluateTurnRun(options: TurnRunOptions): Promise<TurnRunResult>;
|
|
85
|
+
/** Names each violated rule like the per-edit steer; one message covers the turn verdict and the missed files. */
|
|
86
|
+
export declare function turnSteer(verdicts: readonly RulesVerdict[], counts: ReadonlyMap<string, number>): string | undefined;
|