@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.
- package/README.md +26 -87
- package/dist/cli/footer.d.ts +13 -1
- package/dist/cli/footer.js +17 -1
- package/dist/cli/review.js +5 -2
- package/dist/context-files.d.ts +26 -0
- package/dist/context-files.js +113 -0
- package/dist/prompt.d.ts +8 -0
- package/dist/prompt.js +5 -0
- package/dist/run-review.d.ts +6 -0
- package/dist/run-review.js +13 -1
- package/dist/tier.d.ts +5 -0
- package/dist/tier.js +2 -1
- package/package.json +1 -2
- package/src/cli/footer.ts +24 -0
- package/src/cli/review.ts +11 -2
- package/src/context-files.ts +111 -0
- package/src/prompt.ts +14 -0
- package/src/run-review.ts +19 -1
- package/src/tier.ts +6 -0
- package/npm-shrinkwrap.json +0 -1679
package/src/context-files.ts
CHANGED
|
@@ -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
|
-
|
|
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] ?? [])
|