@vintasoftware/pr-review-canvas 0.1.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/LICENSE +202 -0
- package/README.md +192 -0
- package/bin/pr-review.mjs +5 -0
- package/docs/reference.md +340 -0
- package/package.json +74 -0
- package/pr-review.config.example.yml +68 -0
- package/prompts/chat-seed.md +64 -0
- package/prompts/generation-format.md +255 -0
- package/prompts/generation-strict.md +34 -0
- package/prompts/generation-surfacing.md +67 -0
- package/prompts/layers-default.md +13 -0
- package/prompts/quality-standards.md +32 -0
- package/skills/pr-review-canvas/SKILL.md +177 -0
- package/src/acpx/acpx.ts +530 -0
- package/src/acpx/agents.ts +85 -0
- package/src/acpx/events.ts +216 -0
- package/src/acpx/ndjson.ts +69 -0
- package/src/acpx/preflight.ts +44 -0
- package/src/canvas/export.ts +95 -0
- package/src/canvas/import.ts +138 -0
- package/src/canvas/name.ts +55 -0
- package/src/canvas/zip.ts +123 -0
- package/src/chat/chat-manager.ts +389 -0
- package/src/chat/context.ts +160 -0
- package/src/chat/seed.ts +71 -0
- package/src/chat/threads.ts +114 -0
- package/src/cli.ts +199 -0
- package/src/commands.ts +424 -0
- package/src/config.ts +142 -0
- package/src/contract/api.ts +190 -0
- package/src/contract/canvas-manifest.ts +29 -0
- package/src/contract/chat.ts +76 -0
- package/src/contract/comments.ts +96 -0
- package/src/contract/discovery.ts +20 -0
- package/src/contract/generation-context.ts +77 -0
- package/src/contract/keys.ts +14 -0
- package/src/contract/links.ts +5 -0
- package/src/contract/mermaid-fences.ts +4 -0
- package/src/contract/review-artifact.ts +324 -0
- package/src/contract/settings.ts +144 -0
- package/src/contract/state.ts +46 -0
- package/src/contract/validation.ts +43 -0
- package/src/git/diff-collector.ts +151 -0
- package/src/git/git.ts +115 -0
- package/src/git/lang.ts +1 -0
- package/src/git/materialize.ts +79 -0
- package/src/git/patch-lines.ts +60 -0
- package/src/github/attachments.ts +288 -0
- package/src/github/capabilities.ts +112 -0
- package/src/github/comments.ts +132 -0
- package/src/github/gh.ts +196 -0
- package/src/github/post-comment.ts +104 -0
- package/src/github/post-review.ts +44 -0
- package/src/github/pr.ts +133 -0
- package/src/github/review-body.ts +72 -0
- package/src/github/threads.ts +63 -0
- package/src/paths.ts +10 -0
- package/src/project-config.ts +219 -0
- package/src/prompt-files.ts +26 -0
- package/src/review/diagram-nodes.ts +227 -0
- package/src/review/doctor.ts +139 -0
- package/src/review/glob.ts +33 -0
- package/src/review/install-skill.ts +107 -0
- package/src/review/normalize.ts +209 -0
- package/src/review/prepare.ts +165 -0
- package/src/review/prompt.ts +233 -0
- package/src/review/publish.ts +209 -0
- package/src/review/skill-command.ts +4 -0
- package/src/review/test-paths.ts +32 -0
- package/src/review/text-length.ts +15 -0
- package/src/review/trim-caps.ts +114 -0
- package/src/review/validate-folds.ts +110 -0
- package/src/review/validate.ts +520 -0
- package/src/server/app.ts +46 -0
- package/src/server/bundle.ts +266 -0
- package/src/server/capped-body.ts +62 -0
- package/src/server/context.ts +174 -0
- package/src/server/env.ts +7 -0
- package/src/server/errors.ts +65 -0
- package/src/server/html.ts +140 -0
- package/src/server/node-server.ts +42 -0
- package/src/server/routes/api.ts +256 -0
- package/src/server/routes/chat-routes.ts +221 -0
- package/src/server/routes/pages.ts +64 -0
- package/src/server/routes/review-routes.ts +245 -0
- package/src/server/routes/static.ts +114 -0
- package/src/server/security.ts +104 -0
- package/src/server/sse.ts +67 -0
- package/src/store/atomic-json.ts +68 -0
- package/src/store/canvas-store.ts +120 -0
- package/src/store/data-dir.ts +29 -0
- package/src/store/derived-store.ts +93 -0
- package/src/store/pr-store.ts +69 -0
- package/src/store/settings-store.ts +152 -0
- package/src/store/state-store.ts +121 -0
- package/static/js/anchors.js +141 -0
- package/static/js/api.js +542 -0
- package/static/js/app.js +418 -0
- package/static/js/ask.js +35 -0
- package/static/js/chat-context.js +137 -0
- package/static/js/chat-scroll.js +114 -0
- package/static/js/chat.js +843 -0
- package/static/js/code-folds.js +200 -0
- package/static/js/commands.js +110 -0
- package/static/js/comment-link.js +37 -0
- package/static/js/composer.js +241 -0
- package/static/js/contract-types.d.ts +59 -0
- package/static/js/deep-link.js +160 -0
- package/static/js/diagram.js +582 -0
- package/static/js/diff-decorations.js +204 -0
- package/static/js/diff-renderer.js +860 -0
- package/static/js/dom.js +145 -0
- package/static/js/download.js +52 -0
- package/static/js/empty-state.js +161 -0
- package/static/js/errors.js +135 -0
- package/static/js/fences.js +90 -0
- package/static/js/header.js +134 -0
- package/static/js/hunks.js +62 -0
- package/static/js/import-zone.js +95 -0
- package/static/js/interactions.js +952 -0
- package/static/js/keyboard.js +131 -0
- package/static/js/keys.js +97 -0
- package/static/js/lang.js +54 -0
- package/static/js/layers.js +596 -0
- package/static/js/links.js +150 -0
- package/static/js/markdown.js +232 -0
- package/static/js/mermaid-fences.js +55 -0
- package/static/js/nav.js +91 -0
- package/static/js/overview.js +85 -0
- package/static/js/points.js +247 -0
- package/static/js/progress.js +49 -0
- package/static/js/proposed-comment.js +133 -0
- package/static/js/quick-questions.js +216 -0
- package/static/js/regenerate.js +69 -0
- package/static/js/review-session.js +257 -0
- package/static/js/scroll-spy.js +66 -0
- package/static/js/selection.js +193 -0
- package/static/js/settings.js +206 -0
- package/static/js/signoff.js +171 -0
- package/static/js/skin.js +56 -0
- package/static/js/store.js +35 -0
- package/static/js/theme.js +56 -0
- package/static/js/threads.js +78 -0
- package/static/js/vendor.d.ts +15 -0
- package/static/styles/base.css +223 -0
- package/static/styles/chat-tools.css +130 -0
- package/static/styles/chat.css +140 -0
- package/static/styles/commands.css +156 -0
- package/static/styles/diff.css +258 -0
- package/static/styles/header.css +114 -0
- package/static/styles/layout.css +123 -0
- package/static/styles/panels.css +152 -0
- package/static/styles/responsive.css +80 -0
- package/static/styles/review-actions.css +124 -0
- package/static/styles/review.css +473 -0
- package/static/styles/skin-github.css +356 -0
- package/static/styles.css +14 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// `pr-review publish`: validate model.json against context.json, normalize, and store the
|
|
2
|
+
// canvas. Nothing is written when the report is not clean.
|
|
3
|
+
import { appendFile } from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import type { CanvasManifest } from '../contract/canvas-manifest.js'
|
|
6
|
+
import { type GenerationContext, GenerationContextSchema } from '../contract/generation-context.js'
|
|
7
|
+
import type { Generator, ReviewArtifact } from '../contract/review-artifact.js'
|
|
8
|
+
import type { ValidationError, ValidationReport } from '../contract/validation.js'
|
|
9
|
+
import { fetchPrMeta } from '../github/pr.js'
|
|
10
|
+
import type { AppContext } from '../server/context.js'
|
|
11
|
+
import { readJson, readText } from '../store/atomic-json.js'
|
|
12
|
+
import { normalize } from './normalize.js'
|
|
13
|
+
import { coveredTestPaths, type ValidationInput, validateModelOutput } from './validate.js'
|
|
14
|
+
|
|
15
|
+
export interface PublishOptions {
|
|
16
|
+
agent: string
|
|
17
|
+
model?: string | undefined
|
|
18
|
+
harness: Generator['harness']
|
|
19
|
+
allowStale: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublishResult {
|
|
23
|
+
status: 'published'
|
|
24
|
+
headSha: string
|
|
25
|
+
reviewJsonPath: string
|
|
26
|
+
attempts: number
|
|
27
|
+
/** Where the canvas shows once the server runs; absent for a change set without a PR. */
|
|
28
|
+
reviewUrl?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The report of a failed publish. The CLI prints one line per error and exits 5. */
|
|
32
|
+
export class ModelInvalidError extends Error {
|
|
33
|
+
readonly report: ValidationReport
|
|
34
|
+
readonly attempts: number
|
|
35
|
+
|
|
36
|
+
constructor(report: ValidationReport, attempts: number) {
|
|
37
|
+
super(`model.json has ${report.errors.length} problem${report.errors.length === 1 ? '' : 's'}`)
|
|
38
|
+
this.name = 'ModelInvalidError'
|
|
39
|
+
this.report = report
|
|
40
|
+
this.attempts = attempts
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class PublishError extends Error {
|
|
45
|
+
readonly code: 'NOT_FOUND' | 'CANVAS_STALE'
|
|
46
|
+
readonly hint: string
|
|
47
|
+
|
|
48
|
+
constructor(code: 'NOT_FOUND' | 'CANVAS_STALE', message: string, hint: string) {
|
|
49
|
+
super(message)
|
|
50
|
+
this.name = 'PublishError'
|
|
51
|
+
this.code = code
|
|
52
|
+
this.hint = hint
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readContext(canvasDir: string): Promise<GenerationContext> {
|
|
57
|
+
const context = await readJson(path.join(canvasDir, 'context.json'), GenerationContextSchema)
|
|
58
|
+
if (context === null) {
|
|
59
|
+
throw new PublishError('NOT_FOUND', `${canvasDir} has no context.json`, 'run `pr-review prepare` first')
|
|
60
|
+
}
|
|
61
|
+
return context
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The file's JSON, or the one SCHEMA error that says it is not JSON. */
|
|
65
|
+
export function parseModelText(text: string, name: string): { raw: unknown } | { error: ValidationError } {
|
|
66
|
+
try {
|
|
67
|
+
return { raw: JSON.parse(text) }
|
|
68
|
+
} catch (err) {
|
|
69
|
+
const reason = err instanceof Error ? err.message : String(err)
|
|
70
|
+
return { error: { code: 'SCHEMA', where: '(root)', message: `${name} is not valid JSON: ${reason}` } }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function readModel(canvasDir: string): Promise<{ raw: unknown } | { error: ValidationError }> {
|
|
75
|
+
const file = path.join(canvasDir, 'model.json')
|
|
76
|
+
const text = await readText(file)
|
|
77
|
+
if (text === null) {
|
|
78
|
+
throw new PublishError('NOT_FOUND', `${file} does not exist`, 'write the model output there and publish again')
|
|
79
|
+
}
|
|
80
|
+
return parseModelText(text, 'model.json')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The current head of the target; a push during generation makes the prepared context stale. */
|
|
84
|
+
async function currentHead(ctx: AppContext, context: GenerationContext): Promise<string> {
|
|
85
|
+
if (context.target.kind === 'pr') {
|
|
86
|
+
return (await fetchPrMeta(ctx.gh, ctx.config.repo, context.target.number)).headSha
|
|
87
|
+
}
|
|
88
|
+
return ctx.git.revParse(context.target.head)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Publish runs since prepare last wrote a context (its `prepared` line), this one included. */
|
|
92
|
+
export function attemptsSincePrepare(log: string): number {
|
|
93
|
+
const lines = log.split('\n').filter(l => l !== '')
|
|
94
|
+
const lastPrepared = lines.map(l => l.split(' ')[1] === 'prepared').lastIndexOf(true)
|
|
95
|
+
return lines.length - (lastPrepared + 1)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Appends one line for this publish run and returns its attempt number. Every field is named, so
|
|
100
|
+
* the file reads without the code: `<time> published attempts=2` and
|
|
101
|
+
* `<time> invalid attempts=1 errors=2 TEXT_TOO_LONG,HUNK_UNASSIGNED`.
|
|
102
|
+
*/
|
|
103
|
+
async function recordAttempt(
|
|
104
|
+
canvasDir: string,
|
|
105
|
+
now: string,
|
|
106
|
+
result: { ok: boolean; errors: ReadonlyArray<{ code: string }> }
|
|
107
|
+
): Promise<number> {
|
|
108
|
+
const file = path.join(canvasDir, 'publish.log')
|
|
109
|
+
const attempts = attemptsSincePrepare((await readText(file)) ?? '') + 1
|
|
110
|
+
const codes = result.errors.map(e => e.code).join(',')
|
|
111
|
+
const line = result.ok
|
|
112
|
+
? `${now} published attempts=${attempts}`
|
|
113
|
+
: `${now} invalid attempts=${attempts} errors=${result.errors.length}${codes === '' ? '' : ` ${codes}`}`
|
|
114
|
+
await appendFile(file, `${line}\n`, 'utf8')
|
|
115
|
+
return attempts
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The validator's input for one canvas. `covered` test paths outside the diff are looked up at the
|
|
120
|
+
* PR head through git, so an untouched test file that covers the change counts as present.
|
|
121
|
+
*/
|
|
122
|
+
export async function validationInput(
|
|
123
|
+
ctx: AppContext,
|
|
124
|
+
context: GenerationContext,
|
|
125
|
+
raw: unknown
|
|
126
|
+
): Promise<ValidationInput> {
|
|
127
|
+
const inDiff = new Set(context.files.map(f => f.path))
|
|
128
|
+
const headPaths = new Set<string>()
|
|
129
|
+
for (const p of coveredTestPaths(raw)) {
|
|
130
|
+
if (!(inDiff.has(p) || headPaths.has(p)) && (await ctx.git.blobSize(context.headSha, p)) !== null) {
|
|
131
|
+
headPaths.add(p)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
files: context.files,
|
|
136
|
+
caps: context.caps,
|
|
137
|
+
limits: context.limits,
|
|
138
|
+
highRisk: context.highRisk,
|
|
139
|
+
headPaths,
|
|
140
|
+
testPatterns: context.tests.patterns,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function buildManifest(context: GenerationContext, artifact: ReviewArtifact, version: string): CanvasManifest {
|
|
145
|
+
const manifest: CanvasManifest = {
|
|
146
|
+
formatVersion: 1,
|
|
147
|
+
tool: { name: 'pr-review', version },
|
|
148
|
+
repo: context.repo,
|
|
149
|
+
headSha: context.headSha,
|
|
150
|
+
mergeBaseSha: context.mergeBaseSha,
|
|
151
|
+
baseRef: context.pr.baseRef,
|
|
152
|
+
headRef: context.pr.headRef,
|
|
153
|
+
generatedAt: artifact.generatedAt,
|
|
154
|
+
generator: artifact.generator,
|
|
155
|
+
}
|
|
156
|
+
if (context.target.kind === 'pr') {
|
|
157
|
+
manifest.prNumber = context.target.number
|
|
158
|
+
}
|
|
159
|
+
return manifest
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function publish(ctx: AppContext, canvasDir: string, opts: PublishOptions): Promise<PublishResult> {
|
|
163
|
+
const context = await readContext(canvasDir)
|
|
164
|
+
if (!opts.allowStale) {
|
|
165
|
+
const head = await currentHead(ctx, context)
|
|
166
|
+
if (head !== context.headSha) {
|
|
167
|
+
throw new PublishError(
|
|
168
|
+
'CANVAS_STALE',
|
|
169
|
+
`the target moved to ${head.slice(0, 7)} while this canvas was prepared for ${context.headSha.slice(0, 7)}`,
|
|
170
|
+
'run `pr-review prepare` again, or pass --allow-stale to publish for the old commit'
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const model = await readModel(canvasDir)
|
|
175
|
+
const result =
|
|
176
|
+
'error' in model
|
|
177
|
+
? { ok: false, errors: [model.error], output: null }
|
|
178
|
+
: validateModelOutput(model.raw, await validationInput(ctx, context, model.raw))
|
|
179
|
+
const now = ctx.now().toISOString()
|
|
180
|
+
const attempts = await recordAttempt(canvasDir, now, result)
|
|
181
|
+
if (!result.ok || result.output === null) {
|
|
182
|
+
throw new ModelInvalidError({ ok: false, errors: result.errors }, attempts)
|
|
183
|
+
}
|
|
184
|
+
const generator: Generator = { agent: opts.agent, harness: opts.harness, attempts }
|
|
185
|
+
if (opts.model !== undefined) {
|
|
186
|
+
generator.model = opts.model
|
|
187
|
+
}
|
|
188
|
+
const artifact = normalize(result.output, {
|
|
189
|
+
pr: context.pr,
|
|
190
|
+
files: context.files,
|
|
191
|
+
highRisk: context.highRisk,
|
|
192
|
+
caps: context.caps,
|
|
193
|
+
generatedAt: now,
|
|
194
|
+
generator,
|
|
195
|
+
testPatterns: context.tests.patterns,
|
|
196
|
+
})
|
|
197
|
+
const manifest = buildManifest(context, artifact, ctx.version)
|
|
198
|
+
await ctx.canvases.write(context.headSha, artifact, manifest, manifest.prNumber)
|
|
199
|
+
const published: PublishResult = {
|
|
200
|
+
status: 'published',
|
|
201
|
+
headSha: context.headSha,
|
|
202
|
+
reviewJsonPath: path.join(ctx.canvases.canvasDir(context.headSha), 'review.json'),
|
|
203
|
+
attempts,
|
|
204
|
+
}
|
|
205
|
+
if (context.target.kind === 'pr') {
|
|
206
|
+
published.reviewUrl = `http://localhost:${ctx.config.port}/review/${context.target.number}`
|
|
207
|
+
}
|
|
208
|
+
return published
|
|
209
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { matchesGlob } from './glob.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What counts as a test file when the project config says nothing: the JavaScript and TypeScript
|
|
6
|
+
* conventions this repository uses. `tests.patterns` in `pr-review.config.yml` replaces the list,
|
|
7
|
+
* so a Python or Go project can name `test_*.py` or `*_test.go` instead.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_TEST_PATTERNS: readonly string[] = ['**/*.test.*', '**/*.spec.*', '**/__tests__/**']
|
|
10
|
+
|
|
11
|
+
/** True when one of the patterns matches the repo-relative path. */
|
|
12
|
+
export function isTestPath(filePath: string, patterns: readonly string[] = DEFAULT_TEST_PATTERNS): boolean {
|
|
13
|
+
return patterns.some(p => matchesGlob(p, filePath))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The path stem a test file covers: `src/__tests__/a.test.ts` → `src/a`. A source file matches
|
|
18
|
+
* when its own stem (path without extension) is the same string. This strips the naming habits
|
|
19
|
+
* of the default patterns, so a project with other patterns still gets `TEST_NOT_LAST` but may
|
|
20
|
+
* not get `TEST_IN_OTHER`, which needs the source file behind a test name.
|
|
21
|
+
*/
|
|
22
|
+
export function coveredStem(testPath: string): string {
|
|
23
|
+
const withoutTestsDir = testPath.replace(/(^|\/)__tests__\//, '$1')
|
|
24
|
+
const ext = path.posix.extname(withoutTestsDir)
|
|
25
|
+
const base = ext === '' ? withoutTestsDir : withoutTestsDir.slice(0, -ext.length)
|
|
26
|
+
return base.replace(/\.(test|spec)$/, '')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sourceStem(filePath: string): string {
|
|
30
|
+
const ext = path.posix.extname(filePath)
|
|
31
|
+
return ext === '' ? filePath : filePath.slice(0, -ext.length)
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The length a reader sees. Caps measure this, so a long link target or a code fence does not eat
|
|
3
|
+
* the budget: `[text](#hunk:a/very/long/path.ts#2)` counts as `text`, backticks and fence lines
|
|
4
|
+
* count as nothing.
|
|
5
|
+
*/
|
|
6
|
+
export function visibleText(markdown: string): string {
|
|
7
|
+
return markdown
|
|
8
|
+
.replace(/^[ \t]*`{3,}[^\n]*$/gm, '')
|
|
9
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
10
|
+
.replace(/`/g, '')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function visibleLength(markdown: string): number {
|
|
14
|
+
return visibleText(markdown).length
|
|
15
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mechanical repairs for text that is over its cap.
|
|
3
|
+
*
|
|
4
|
+
* Caps are measured on the text a reader sees, which a writer cannot count while writing, so an
|
|
5
|
+
* over-cap field usually comes back over-cap after a rewrite. Two things help without touching
|
|
6
|
+
* meaning: trimming a title's trailing explainer, which the length rules ask for anyway, and
|
|
7
|
+
* telling the writer where the cap actually falls in their own text.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { visibleLength, visibleText } from './text-length.js'
|
|
11
|
+
|
|
12
|
+
/** Separators that introduce an explainer after a name: "Storage: how it works". */
|
|
13
|
+
const EXPLAINER = /\s+[:—–-]\s+|:\s+/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The visible prefix that fits in `cap`, ending on a word boundary when one is near the cut.
|
|
17
|
+
* Used to tell a writer where their text crosses the cap, never to rewrite prose for them.
|
|
18
|
+
*/
|
|
19
|
+
export function visiblePrefix(text: string, cap: number): string {
|
|
20
|
+
const visible = visibleText(text)
|
|
21
|
+
if (visible.length <= cap) {
|
|
22
|
+
return visible
|
|
23
|
+
}
|
|
24
|
+
const cut = visible.slice(0, cap)
|
|
25
|
+
if (/\s/.test(visible.charAt(cap))) {
|
|
26
|
+
return cut.trimEnd()
|
|
27
|
+
}
|
|
28
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
29
|
+
return lastSpace > 0 && lastSpace > cap - 24 ? cut.slice(0, lastSpace) : cut
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A title with its trailing explainer removed, when dropping it brings the title under the cap.
|
|
34
|
+
* Returns null when the title already fits or when no cut helps, so the caller leaves it alone.
|
|
35
|
+
*
|
|
36
|
+
* Only the first separator counts: "Endpoints: update, create" keeps "Endpoints", and a title
|
|
37
|
+
* with no separator is never cut mid-phrase — a truncated name is worse than a long one.
|
|
38
|
+
*/
|
|
39
|
+
export function trimTitle(title: string, cap: number): string | null {
|
|
40
|
+
const tidied = title.trim().replace(/\s+/g, ' ').replace(/\.$/, '')
|
|
41
|
+
if (visibleLength(tidied) <= cap) {
|
|
42
|
+
return tidied === title ? null : tidied
|
|
43
|
+
}
|
|
44
|
+
const match = EXPLAINER.exec(tidied)
|
|
45
|
+
if (match === undefined || match === null || match.index === 0) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
const head = tidied.slice(0, match.index).trim()
|
|
49
|
+
return head !== '' && visibleLength(head) <= cap ? head : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A title this pass repaired or could not shorten safely. */
|
|
53
|
+
export type TitleTrim =
|
|
54
|
+
| { outcome: 'fixed'; where: string; from: string; to: string }
|
|
55
|
+
| { outcome: 'unfixable'; where: string; length: number; cap: number; reason: string }
|
|
56
|
+
|
|
57
|
+
interface TitledFold {
|
|
58
|
+
title?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface TitledFile {
|
|
62
|
+
folds?: TitledFold[]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TitledLayer {
|
|
66
|
+
title?: string
|
|
67
|
+
files?: TitledFile[]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TitledOutput {
|
|
71
|
+
layers?: TitledLayer[]
|
|
72
|
+
points?: Array<{ title?: string }>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Trims every title of a parsed model that is over its cap, in place, and reports what changed.
|
|
77
|
+
* Titles only: a rationale or a body is prose a reader will read, and cutting it is the author's
|
|
78
|
+
* call, not the tool's.
|
|
79
|
+
*/
|
|
80
|
+
export function applyTitleTrims(output: unknown, caps: { layerTitle: number; pointTitle: number }): TitleTrim[] {
|
|
81
|
+
const trims: TitleTrim[] = []
|
|
82
|
+
const trim = (where: string, holder: { title?: string }, cap: number): void => {
|
|
83
|
+
if (typeof holder.title !== 'string') {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const trimmed = trimTitle(holder.title, cap)
|
|
87
|
+
if (trimmed !== null) {
|
|
88
|
+
trims.push({ outcome: 'fixed', where, from: holder.title, to: trimmed })
|
|
89
|
+
holder.title = trimmed
|
|
90
|
+
} else if (visibleLength(holder.title) > cap) {
|
|
91
|
+
const match = EXPLAINER.exec(holder.title.trim().replace(/\s+/g, ' '))
|
|
92
|
+
const reason = match === null
|
|
93
|
+
? 'no explainer separator (:, —, – or -) to drop'
|
|
94
|
+
: match.index === 0
|
|
95
|
+
? 'dropping the explainer would leave an empty title'
|
|
96
|
+
: 'the title before the explainer still exceeds the cap'
|
|
97
|
+
trims.push({ outcome: 'unfixable', where, length: visibleLength(holder.title), cap, reason })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const model = output as TitledOutput
|
|
102
|
+
model.layers?.forEach((layer, i) => {
|
|
103
|
+
trim(`layers.${i}.title`, layer, caps.layerTitle)
|
|
104
|
+
layer.files?.forEach((file, j) => {
|
|
105
|
+
file.folds?.forEach((fold, k) => {
|
|
106
|
+
trim(`layers.${i}.files.${j}.folds.${k}.title`, fold, caps.pointTitle)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
model.points?.forEach((point, i) => {
|
|
111
|
+
trim(`points.${i}.title`, point, caps.pointTitle)
|
|
112
|
+
})
|
|
113
|
+
return trims
|
|
114
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { CodeFold, FileEntry, Hunk, ModelLayer, ModelOutput } from '../contract/review-artifact.js'
|
|
2
|
+
import type { ValidationError } from '../contract/validation.js'
|
|
3
|
+
import { hunkForLine } from '../git/patch-lines.js'
|
|
4
|
+
|
|
5
|
+
type SourceRange = Pick<CodeFold, 'side' | 'startLine' | 'endLine'>
|
|
6
|
+
type ModelFile = ModelLayer['files'][number]
|
|
7
|
+
|
|
8
|
+
function assignedHunk(fold: CodeFold, file: ModelFile, hunks: readonly Hunk[]): Hunk | null {
|
|
9
|
+
if (fold.endLine < fold.startLine) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const start = hunkForLine(hunks, fold.side, fold.startLine)
|
|
14
|
+
const end = hunkForLine(hunks, fold.side, fold.endLine)
|
|
15
|
+
|
|
16
|
+
if (start === null || end?.id !== start.id || !file.hunks.includes(start.id)) {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return start
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Different coordinate sides in one hunk need the full patch to prove they are separate. */
|
|
24
|
+
function rangesOverlap(left: SourceRange, right: SourceRange, hunks: readonly Hunk[]): boolean {
|
|
25
|
+
const leftHunk = hunkForLine(hunks, left.side, left.startLine)
|
|
26
|
+
const rightHunk = hunkForLine(hunks, right.side, right.startLine)
|
|
27
|
+
|
|
28
|
+
if (leftHunk === null || leftHunk.id !== rightHunk?.id) {
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return left.side !== right.side || (left.startLine <= right.endLine && right.startLine <= left.endLine)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function protectedRanges(
|
|
36
|
+
file: ModelFile,
|
|
37
|
+
layer: ModelLayer,
|
|
38
|
+
output: ModelOutput,
|
|
39
|
+
hunks: readonly Hunk[]
|
|
40
|
+
): SourceRange[] {
|
|
41
|
+
const ranges: SourceRange[] = [...file.annotations]
|
|
42
|
+
const points = output.points.filter(point => point.path === file.path)
|
|
43
|
+
|
|
44
|
+
for (const point of points) {
|
|
45
|
+
const side = point.side ?? 'new'
|
|
46
|
+
const hunk = hunkForLine(hunks, side, point.line)
|
|
47
|
+
|
|
48
|
+
if (hunk !== null && file.hunks.includes(hunk.id)) {
|
|
49
|
+
ranges.push({ side, startLine: point.line, endLine: point.endLine ?? point.line })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const needsTestPoint = layer.files[0] === file && layer.tests.some(test => test.status === 'missing')
|
|
54
|
+
const firstHunk = needsTestPoint ? hunks.find(hunk => hunk.id === file.hunks[0]) : undefined
|
|
55
|
+
|
|
56
|
+
if (firstHunk !== undefined) {
|
|
57
|
+
const side = firstHunk.newLines === 0 ? 'old' : 'new'
|
|
58
|
+
const line = side === 'new' ? firstHunk.newStart : firstHunk.oldStart
|
|
59
|
+
ranges.push({ side, startLine: line, endLine: line })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ranges
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateFileFolds(
|
|
66
|
+
file: ModelFile,
|
|
67
|
+
layer: ModelLayer,
|
|
68
|
+
output: ModelOutput,
|
|
69
|
+
hunks: readonly Hunk[]
|
|
70
|
+
): ValidationError[] {
|
|
71
|
+
const errors: ValidationError[] = []
|
|
72
|
+
const where = `layer:${layer.key}/file:${file.path}`
|
|
73
|
+
const protectedCode = protectedRanges(file, layer, output, hunks)
|
|
74
|
+
const folds = file.folds ?? []
|
|
75
|
+
|
|
76
|
+
const fail = (message: string): void => {
|
|
77
|
+
errors.push({ code: 'FOLD_INVALID', where, message: `${where}: ${message}` })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (file.collapsed && protectedCode.length > 0) {
|
|
81
|
+
fail('a file with annotations or attention points must start expanded')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [index, fold] of folds.entries()) {
|
|
85
|
+
if (assignedHunk(fold, file, hunks) === null) {
|
|
86
|
+
fail(`fold ${index + 1} must be an ordered range inside one hunk assigned to this file in this layer`)
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const earlierFolds = folds.slice(0, index)
|
|
91
|
+
if (earlierFolds.some(earlier => rangesOverlap(fold, earlier, hunks))) {
|
|
92
|
+
fail(`fold ${index + 1} overlaps an earlier fold or uses another coordinate side in the same hunk`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (protectedCode.some(range => rangesOverlap(fold, range, hunks))) {
|
|
96
|
+
fail(`fold ${index + 1} would hide an annotation or attention point`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return errors
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Checks fold coordinates and keeps attention points and annotations visible. */
|
|
104
|
+
export function validateFolds(output: ModelOutput, files: readonly FileEntry[]): ValidationError[] {
|
|
105
|
+
const byPath = new Map(files.map(file => [file.path, file]))
|
|
106
|
+
|
|
107
|
+
return output.layers.flatMap(layer =>
|
|
108
|
+
layer.files.flatMap(file => validateFileFolds(file, layer, output, byPath.get(file.path)?.hunks ?? []))
|
|
109
|
+
)
|
|
110
|
+
}
|