@verevoir/review 0.3.2 → 0.3.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.
@@ -89,6 +89,117 @@ export function diffPaths(diff: string): DiffPath[] {
89
89
  return out;
90
90
  }
91
91
 
92
+ function stripEither(path: string): string {
93
+ return path.startsWith('a/') || path.startsWith('b/') ? path.slice(2) : path;
94
+ }
95
+
96
+ /** The `a/X b/X` header of a `diff --git` line, when both halves are the same path. */
97
+ function gitHeaderPath(line: string): string | undefined {
98
+ const rest = line.slice('diff --git '.length);
99
+ if (rest.startsWith('"')) return undefined;
100
+ const mid = (rest.length - 1) / 2;
101
+ if (!Number.isInteger(mid) || rest[mid] !== ' ') return undefined;
102
+ const left = rest.slice(0, mid);
103
+ const right = rest.slice(mid + 1);
104
+ if (!left.startsWith('a/') || !right.startsWith('b/')) return undefined;
105
+ return left.slice(2) === right.slice(2) ? left.slice(2) : undefined;
106
+ }
107
+
108
+ /**
109
+ * EVERY path a diff changes, for deciding what kind of change it is (not for reading files).
110
+ * Unlike diffPaths it also sees what git writes WITHOUT `---`/`+++` headers: a pure rename
111
+ * (`rename from` / `rename to`), a copy, a binary change (`Binary files A and B differ`) and a
112
+ * mode-only change (the `diff --git` line itself). Both sides of a rename and the old path of a
113
+ * deletion are included, so moving code into a .md file is not a documentation change. A path
114
+ * that cannot be determined is reported as '' so the caller treats the diff as not understood.
115
+ * A diff with no `diff --git` lines (plain unified) falls back to diffPaths.
116
+ */
117
+ export function changedPaths(diff: string): string[] {
118
+ const lines = diff.split(/\r?\n/);
119
+ if (!lines.some((l) => l.startsWith('diff --git '))) {
120
+ return diffPaths(diff).map((p) => p.path);
121
+ }
122
+ const out = new Set<string>();
123
+ let block: string[] = [];
124
+ const flush = (): void => {
125
+ if (block.length === 0) return;
126
+ const found: string[] = [];
127
+ let bad = false;
128
+ const take = (raw: string, strip: boolean): void => {
129
+ const value = unquote(raw);
130
+ if (value === undefined || value === '') bad = true;
131
+ else if (value !== '/dev/null') found.push(strip ? stripEither(value) : value);
132
+ };
133
+ for (const line of block.slice(1)) {
134
+ if (line.startsWith('@@')) break;
135
+ if (line.startsWith('rename from ')) take(line.slice(12), false);
136
+ else if (line.startsWith('rename to ')) take(line.slice(10), false);
137
+ else if (line.startsWith('copy from ')) take(line.slice(10), false);
138
+ else if (line.startsWith('copy to ')) take(line.slice(8), false);
139
+ else if (line.startsWith('--- ') || line.startsWith('+++ ')) take(line.slice(4), true);
140
+ else {
141
+ const binary = /^Binary files (.+) and (.+) differ$/.exec(line);
142
+ if (binary !== null) {
143
+ take(binary[1] ?? '', true);
144
+ take(binary[2] ?? '', true);
145
+ }
146
+ }
147
+ }
148
+ if (found.length === 0 && !bad) {
149
+ const header = gitHeaderPath(block[0] ?? '');
150
+ if (header === undefined) bad = true;
151
+ else found.push(header);
152
+ }
153
+ if (bad) out.add('');
154
+ for (const p of found) out.add(p);
155
+ };
156
+ for (const line of lines) {
157
+ if (line.startsWith('diff --git ')) {
158
+ flush();
159
+ block = [line];
160
+ } else if (block.length > 0) {
161
+ block.push(line);
162
+ }
163
+ }
164
+ flush();
165
+ return [...out];
166
+ }
167
+
168
+ /** A documentation file type, at any depth. BACKLOG.md is covered by .md. */
169
+ const DOCUMENTATION_FILE = /\.(?:md|mdx|rst|adoc)$/i;
170
+
171
+ /** Documentation by FILE TYPE (.md .mdx .rst .adoc, any depth; not .txt, which also names requirements.txt and CMakeLists.txt); a directory name alone is not enough. */
172
+ export function isDocumentationPath(path: string): boolean {
173
+ return DOCUMENTATION_FILE.test(path);
174
+ }
175
+
176
+ /**
177
+ * True when the diff changes at least one file and EVERY changed path is documentation. Drives
178
+ * the tier (promotion off) and the spec framing. Paths come from changedPaths; one that cannot
179
+ * be determined makes the diff NOT docs-only, so promotion stays on.
180
+ */
181
+ export function isDocumentationOnly(diff: string): boolean {
182
+ return classifyDiff(diff).documentationOnly;
183
+ }
184
+
185
+ export interface DiffClassification {
186
+ /** At least one changed file, and every one is documentation. */
187
+ readonly documentationOnly: boolean;
188
+ /** At least one changed path could not be determined (so the diff is never docs-only). */
189
+ readonly undeterminedPath: boolean;
190
+ }
191
+
192
+ /** The single classification of a diff: callers compute it once and pass the answer down. */
193
+ export function classifyDiff(diff: string): DiffClassification {
194
+ const paths = changedPaths(diff);
195
+ const undeterminedPath = paths.includes('');
196
+ return {
197
+ documentationOnly:
198
+ paths.length > 0 && !undeterminedPath && paths.every((p) => isDocumentationPath(p)),
199
+ undeterminedPath,
200
+ };
201
+ }
202
+
92
203
  /** Why a diff-supplied path must not be read, or undefined if it is acceptable. */
93
204
  export function unsafePathReason(path: string): string | undefined {
94
205
  if (path === '') return 'path could not be read from the diff header';
package/src/prompt.ts CHANGED
@@ -40,6 +40,7 @@
40
40
  // equality against it.
41
41
 
42
42
  import type { Lens } from './lensSet.js';
43
+ import { isDocumentationOnly } from './context-files.js';
43
44
  import {
44
45
  REPLY_CONTRACT,
45
46
  RULING_MARKER,
@@ -125,6 +126,12 @@ export interface Subject {
125
126
  * `buildPrompt` below for exactly how that slice is picked out.
126
127
  */
127
128
  readonly priorFindings?: ReadonlyArray<PriorFinding>;
129
+ /**
130
+ * Whether every changed file is documentation (context-files.ts classifyDiff). A caller that
131
+ * has already classified the diff passes the answer so it is decided once; absent, buildPrompt
132
+ * classifies `diff` itself. True adds SPEC_FRAMING to the shared prefix.
133
+ */
134
+ readonly docsOnly?: boolean;
128
135
  }
129
136
 
130
137
  /**
@@ -178,6 +185,10 @@ export const NO_REQUIREMENT_NOTICE =
178
185
  'from outside the run to check it against. Say so plainly in your ' +
179
186
  'reasoning, and judge only whether the change is coherent on its own terms.';
180
187
 
188
+ /** Added to the shared prefix when every changed file is documentation (see below). */
189
+ export const SPEC_FRAMING =
190
+ "This change is a specification or plan. Block only where its text commits to behaviour a practice rejects. Where it merely doesn't rule out a case, raise a smell.";
191
+
181
192
  /** Heading of the shared check-results section (`Subject.checks`). */
182
193
  export const CHECKS_HEADING = '## Checks on this change';
183
194
 
@@ -318,9 +329,12 @@ export function buildPrompt(
318
329
  ? ['', PRIOR_FINDINGS_HEADING, '', ...rulingSection, '', ...independentReviewSection]
319
330
  : [];
320
331
 
332
+ const specification = subject.docsOnly ?? isDocumentationOnly(subject.diff);
333
+
321
334
  const shared = [
322
335
  'You are one lens of a review panel. Several reviewers are reading this same change, each along a different dimension, and their verdicts are combined. Review only your own dimension: another lens is covering the rest, and a finding outside your dimension is noise in someone else’s report.',
323
336
  '',
337
+ ...(specification ? [SPEC_FRAMING, ''] : []),
324
338
  ...barInShared,
325
339
  '## Requirement',
326
340
  '',
package/src/run-review.ts CHANGED
@@ -31,6 +31,7 @@
31
31
  // unchanged from v5, where this was also true.
32
32
 
33
33
  import type { LensSet } from './lensSet.js';
34
+ import { classifyDiff } from './context-files.js';
34
35
  import type { Prompt, Subject } from './prompt.js';
35
36
  import { runPanel } from './panel.js';
36
37
  import { exitCodeFor, renderReport } from './report.js';
@@ -108,6 +109,12 @@ export interface ReviewNodeResult {
108
109
  readonly downgraded: number;
109
110
  /** How many smells tier enforcement promoted to [blocking] (0 when none). */
110
111
  readonly promoted: number;
112
+ /** True when Reject promotion was switched off because every changed file is documentation. */
113
+ readonly promotionOff: boolean;
114
+ /** True when every changed file is documentation, whether or not a tier applied. */
115
+ readonly docsOnly: boolean;
116
+ /** True when a changed path could not be determined, so the diff was not treated as docs-only. */
117
+ readonly pathUndetermined: boolean;
111
118
  /** Stderr lines, one per member of every merged dedup group (empty when none merged). */
112
119
  readonly dedupAudit: readonly string[];
113
120
  }
@@ -195,14 +202,22 @@ export async function runReviewNode(
195
202
  ? warmFirst(observedReviewer)
196
203
  : observedReviewer;
197
204
 
198
- const panel = await runPanel(input.lensSet, input.subject, dispatched, input.practices);
205
+ // The docs-only decision is made ONCE, here (context-files.ts classifyDiff). It reaches the
206
+ // prompt through the subject (spec framing, every mode) and the tier below (promotion off).
207
+ const docs = classifyDiff(input.subject.diff);
208
+ const subject = { ...input.subject, docsOnly: docs.documentationOnly };
209
+ const panel = await runPanel(input.lensSet, subject, dispatched, input.practices);
199
210
  const prompts = panel.prompts;
200
211
 
212
+ // Promotion is off when every changed file in the parsed diff is documentation
213
+ // (classified above by context-files.ts); with no tier signals there is no promotion to turn off.
214
+ const promotionOff = input.tierSignals !== undefined && docs.documentationOnly;
201
215
  // C1 runs before C2: de-duplication sees the tiers as enforced.
202
216
  const tiered =
203
217
  input.tierSignals === undefined
204
218
  ? { verdict: panel.verdict, downgraded: 0, promoted: 0 }
205
219
  : enforceTier(panel.verdict, input.tierSignals, {
220
+ ...(promotionOff ? { promote: false } : {}),
206
221
  ...(input.tierRejects === undefined ? {} : { rejects: input.tierRejects }),
207
222
  ...(input.practiceIds === undefined ? {} : { practiceIds: input.practiceIds }),
208
223
  });
@@ -237,6 +252,9 @@ export async function runReviewNode(
237
252
  prompts,
238
253
  downgraded: tiered.downgraded,
239
254
  promoted: tiered.promoted,
255
+ promotionOff,
256
+ docsOnly: docs.documentationOnly,
257
+ pathUndetermined: docs.undeterminedPath,
240
258
  dedupAudit,
241
259
  };
242
260
  }
package/src/tier.ts CHANGED
@@ -128,6 +128,11 @@ export interface TierContext {
128
128
  readonly rejects?: Readonly<Record<string, readonly string[]>>;
129
129
  /** Every practice id in the corpus, ignored when matching. */
130
130
  readonly practiceIds?: readonly string[];
131
+ /**
132
+ * False turns Reject-quote promotion off (every changed file is documentation; see
133
+ * context-files.ts isDocumentationOnly). The C1 downgrade is unaffected. Default true.
134
+ */
135
+ readonly promote?: boolean;
131
136
  }
132
137
 
133
138
  export interface TierResult {
@@ -158,6 +163,7 @@ export function enforceTier(
158
163
  if (!Object.prototype.hasOwnProperty.call(signals, result.lens)) return result;
159
164
  const own = signals[result.lens] ?? [];
160
165
  const rejectLines =
166
+ context.promote !== false &&
161
167
  context.rejects !== undefined &&
162
168
  Object.prototype.hasOwnProperty.call(context.rejects, result.lens)
163
169
  ? (context.rejects[result.lens] ?? [])