@plainconceptsplatform/agent-harness 3.9.0 → 3.10.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 +21 -21
- package/README.md +551 -551
- package/cli/commands/join.js +259 -259
- package/cli/commands/migrate.js +310 -310
- package/cli/commands/shared.js +27 -27
- package/cli/commands/single.js +79 -79
- package/cli/commands/update.js +121 -121
- package/cli/commands/wizard.js +134 -134
- package/cli/fragments/archive/az.md +97 -97
- package/cli/fragments/archive/gh.md +96 -96
- package/cli/fragments/archive/gl.md +96 -96
- package/cli/fragments/archive/none.md +75 -75
- package/cli/fragments/guardrails/codegraph.md +5 -5
- package/cli/fragments/guardrails/humanizer.md +4 -4
- package/cli/fragments/guardrails/memory.md +4 -4
- package/cli/fragments/guardrails/rtk.md +3 -3
- package/cli/fragments/guardrails/simple-english.md +4 -4
- package/cli/fragments/ops-backlog/az.md +28 -28
- package/cli/fragments/ops-backlog/gh.md +28 -28
- package/cli/fragments/ops-backlog/jira.md +28 -28
- package/cli/fragments/ops-review/az.md +62 -62
- package/cli/fragments/ops-review/gh.md +51 -51
- package/cli/fragments/ops-review/gl.md +55 -55
- package/cli/fragments/ops-ship/az.md +81 -81
- package/cli/fragments/ops-ship/gh.md +67 -67
- package/cli/fragments/ops-ship/gl.md +84 -84
- package/cli/index.js +116 -116
- package/cli/presets/agents-content.json +34 -34
- package/cli/presets/browser.json +10 -10
- package/cli/presets/clean.json +21 -21
- package/cli/presets/models.json +68 -68
- package/cli/presets/optimization.json +32 -32
- package/cli/presets/platforms.json +76 -76
- package/cli/presets/source.json +23 -23
- package/cli/steps/browser/index.js +52 -48
- package/cli/steps/clean/index.js +120 -120
- package/cli/steps/copy/agents.js +176 -176
- package/cli/steps/copy/commands.js +79 -79
- package/cli/steps/copy/fullstack-engineer.js +81 -81
- package/cli/steps/copy/index.js +88 -88
- package/cli/steps/copy/opencode-json.js +359 -324
- package/cli/steps/copy/skills.js +316 -316
- package/cli/steps/metadata/index.js +115 -115
- package/cli/steps/models/format.js +88 -88
- package/cli/steps/models/index.js +64 -64
- package/cli/steps/models/write.js +38 -38
- package/cli/steps/openspec/index.js +136 -136
- package/cli/steps/optimization/codegraph.js +141 -141
- package/cli/steps/optimization/detect.js +66 -66
- package/cli/steps/optimization/humanizer.js +17 -17
- package/cli/steps/optimization/index.js +158 -158
- package/cli/steps/optimization/memory.js +91 -91
- package/cli/steps/optimization/patch-guardrails.js +111 -111
- package/cli/steps/optimization/simple-english.js +17 -17
- package/cli/steps/optimization/skills-lock.js +30 -30
- package/cli/steps/platform/index.js +109 -109
- package/cli/steps/source/index.js +123 -123
- package/cli/utils/agent-color.js +111 -111
- package/cli/utils/copy.js +134 -134
- package/cli/utils/exec-spinner.js +47 -47
- package/cli/utils/exec.js +134 -134
- package/cli/utils/legacy-check.js +30 -30
- package/cli/utils/models-cache.js +58 -58
- package/cli/utils/models-pricing.js +42 -42
- package/cli/utils/paths.js +82 -82
- package/cli/utils/process.js +3 -3
- package/cli/utils/terminal.js +6 -6
- package/cli/utils/update-manifest.js +75 -75
- package/harness/.opencode/_gitignore +12 -12
- package/harness/.opencode/commands/init.md +5 -5
- package/harness/.opencode/commands/make-architecture.md +5 -5
- package/harness/.opencode/commands/make-design.md +5 -5
- package/harness/.opencode/commands/make-engineer.md +5 -5
- package/harness/.opencode/commands/make-guardrails.md +5 -5
- package/harness/.opencode/commands/make-user-model.md +5 -5
- package/harness/.opencode/commands/ops-backlog.md +10 -10
- package/harness/.opencode/commands/ops-review.md +10 -10
- package/harness/.opencode/commands/ops-ship.md +9 -9
- package/harness/.opencode/commands/plan-apply.md +9 -9
- package/harness/.opencode/commands/plan-archive.md +7 -7
- package/harness/.opencode/commands/plan-explore.md +11 -11
- package/harness/.opencode/commands/plan-goal.md +5 -5
- package/harness/.opencode/commands/plan-propose.md +11 -11
- package/harness/.opencode/commands/plan-quick.md +7 -7
- package/harness/.opencode/commands/plan-story.md +11 -11
- package/harness/.opencode/commands/repo-audit.md +7 -7
- package/harness/.opencode/commands/repo-help.md +5 -5
- package/harness/.opencode/commands/repo-initialize.md +5 -5
- package/harness/.opencode/commands/repo-onboard.md +5 -5
- package/harness/.opencode/commands/repo-verify.md +5 -5
- package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
- package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
- package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
- package/harness/.opencode/scripts/check-diff.mjs +305 -305
- package/harness/.opencode/scripts/code-rules.mjs +288 -288
- package/harness/.opencode/scripts/platform-contract.mjs +366 -366
- package/harness/AGENTS.md +55 -55
- package/harness/ARCHITECTURE.md +13 -13
- package/harness/DESIGN.md +13 -13
- package/harness/opencode.jsonc +37 -36
- package/harness/openspec/config.yaml +20 -20
- package/harness/skills-lock.json +17 -17
- package/package.json +14 -12
- package/skills/README.md +18 -18
- package/skills/agent-harness-cli/SKILL.md +86 -86
- package/skills/browser-automation/SKILL.md +100 -72
- package/skills/pc-guardrails-generic/SKILL.md +53 -53
- package/skills/pc-guardrails-project/SKILL.md +8 -8
- package/skills/pc-make-architecture/SKILL.md +33 -33
- package/skills/pc-make-architecture/structure-template.md +64 -64
- package/skills/pc-make-design/SKILL.md +45 -45
- package/skills/pc-make-engineer/SKILL.md +59 -59
- package/skills/pc-make-engineer/signal-mapping.md +53 -53
- package/skills/pc-make-engineer/template.md +36 -36
- package/skills/pc-make-guardrails/SKILL.md +44 -44
- package/skills/pc-make-guardrails/category-reference.md +112 -112
- package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
- package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
- package/skills/pc-make-user-model/SKILL.md +56 -56
- package/skills/pc-ops-ship/SKILL.md +22 -22
- package/skills/pc-plan-apply/SKILL.md +90 -90
- package/skills/pc-plan-apply/simple-mode.md +21 -21
- package/skills/pc-plan-archive/SKILL.md +66 -66
- package/skills/pc-plan-explore/SKILL.md +21 -21
- package/skills/pc-plan-goal/SKILL.md +91 -91
- package/skills/pc-plan-goal/branching.md +39 -39
- package/skills/pc-plan-goal/failure-policy.md +30 -30
- package/skills/pc-plan-goal/output-mode.md +10 -10
- package/skills/pc-plan-goal/output.md +71 -71
- package/skills/pc-plan-propose/SKILL.md +127 -127
- package/skills/pc-plan-propose/task-annotation.md +39 -39
- package/skills/pc-plan-quick/SKILL.md +46 -46
- package/skills/pc-plan-story/SKILL.md +93 -93
- package/skills/pc-repo-audit/SKILL.md +44 -44
- package/skills/pc-repo-help/SKILL.md +87 -87
- package/skills/pc-repo-initialize/SKILL.md +119 -119
- package/skills/pc-repo-onboard/SKILL.md +32 -32
- package/skills/pc-repo-verify/SKILL.md +150 -150
- package/skills/pc-userstory-az/SKILL.md +71 -71
- package/skills/pc-userstory-browser/SKILL.md +73 -64
- package/skills/pc-userstory-gh/SKILL.md +63 -63
- package/skills/pc-userstory-jira/SKILL.md +74 -74
|
@@ -1,305 +1,305 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process'
|
|
2
|
-
import fs from 'node:fs'
|
|
3
|
-
import path from 'node:path'
|
|
4
|
-
import { pathToFileURL } from 'node:url'
|
|
5
|
-
import { commentBlocks, countLines, isGenerated, kindOf, lineKinds, slopFindings, testStats } from './code-rules.mjs'
|
|
6
|
-
import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract, nameKey, reusableNames } from './platform-contract.mjs'
|
|
7
|
-
|
|
8
|
-
export const DEFAULT_LIMITS = {
|
|
9
|
-
commentRatio: 0.1,
|
|
10
|
-
commentAllowance: 2,
|
|
11
|
-
testRatio: 1,
|
|
12
|
-
testAllowance: 40,
|
|
13
|
-
casesPerKloc: 40,
|
|
14
|
-
caseAllowance: 3,
|
|
15
|
-
complexityBaseline: 'scripts/complexity-baseline.json',
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const FIX = {
|
|
19
|
-
'comment-budget': 'Delete comments until the diff is within budget. Keep only one that explains a constraint the code cannot show: a third-party bug, an external contract, a legal requirement.',
|
|
20
|
-
'banner': 'Delete the separator.',
|
|
21
|
-
'region': 'Delete the region markers.',
|
|
22
|
-
'untracked-todo': 'Link an issue or delete the TODO.',
|
|
23
|
-
'restating-summary': 'Delete the doc comment: it repeats the name.',
|
|
24
|
-
'restating-jsdoc': 'Delete the doc comment: it repeats the name.',
|
|
25
|
-
'restating-param': 'Delete the parameter doc: it repeats the name.',
|
|
26
|
-
'filler-returns': 'Delete the returns doc: it says nothing the signature does not.',
|
|
27
|
-
'commented-out-code': 'Delete it; git keeps the history.',
|
|
28
|
-
'apologetic': 'Make the code right or delete the note.',
|
|
29
|
-
'history': 'Delete the incident story; it belongs in the commit message or the pull request.',
|
|
30
|
-
'essay': 'Cut the block to one line or delete it; long explanations belong in docs or the pull request.',
|
|
31
|
-
'internal-doc': 'Delete the doc comment: nothing outside the file calls this.',
|
|
32
|
-
'touched-file-slop': 'This file is in the diff, so leave it clean: delete the finding even though it predates the change.',
|
|
33
|
-
'touched-file-density': 'This file now has more comments per code line than before. Delete comments until it does not.',
|
|
34
|
-
'test-budget': 'Too much test code for the change. Keep the tests of the public contract; a bug fix gets one regression test.',
|
|
35
|
-
'case-budget': 'Too many test cases for the change. Merge or delete the ones that test internals or repeat each other.',
|
|
36
|
-
'test-skipped': 'Delete the skipped test or make it run.',
|
|
37
|
-
'test-focused': 'Remove .only / f-prefix: it hides every other test.',
|
|
38
|
-
'test-snapshot': 'Assert the values that matter instead of a snapshot.',
|
|
39
|
-
'test-no-assertion': 'Assert something, or delete the test.',
|
|
40
|
-
'test-mock-only': 'Assert on state or output, not only that a mock was called.',
|
|
41
|
-
'platform-file': 'This file is owned by the template named in the platform contract. Ask for the change there, or add a dated exception.',
|
|
42
|
-
'platform-contract': 'Fix the contract: until it reads, no platform file can be checked.',
|
|
43
|
-
'reuse-shadow': 'The platform contract marks this name for reuse. Use the existing one, or rename the file if it is something else.',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const SUSPICIOUS = {
|
|
47
|
-
skipped: 'test-skipped',
|
|
48
|
-
focused: 'test-focused',
|
|
49
|
-
snapshot: 'test-snapshot',
|
|
50
|
-
noAssertion: 'test-no-assertion',
|
|
51
|
-
mockOnly: 'test-mock-only',
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const EMPTY_STATS = { cases: 0, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
|
|
55
|
-
|
|
56
|
-
// `tracked` holds the files a complexity gate has methods on record for. Their new lines buy twice
|
|
57
|
-
// the test budget: that gate fails a method whose coverage drops, so a change there needs its new
|
|
58
|
-
// branches tested, and trimming those tests to fit this budget is what broke Pliny-Bot #633.
|
|
59
|
-
export function evaluate(files, limits = DEFAULT_LIMITS, tracked = new Set()) {
|
|
60
|
-
const violations = []
|
|
61
|
-
const add = (rule, file, line, detail) => violations.push({ rule, file, line, detail, fix: FIX[rule] })
|
|
62
|
-
const totals = { sourceCode: 0, trackedCode: 0, testCode: 0, comments: 0, testLines: 0, cases: 0 }
|
|
63
|
-
const commentsByFile = new Map()
|
|
64
|
-
|
|
65
|
-
for (const file of files) {
|
|
66
|
-
const kind = kindOf(file.path)
|
|
67
|
-
if (!kind || isGenerated(file.after)) continue
|
|
68
|
-
const kinds = lineKinds(file.after)
|
|
69
|
-
for (const n of file.added) {
|
|
70
|
-
const k = kinds[n - 1]
|
|
71
|
-
if (k === 'comment') {
|
|
72
|
-
totals.comments += 1
|
|
73
|
-
commentsByFile.set(file.path, (commentsByFile.get(file.path) ?? 0) + 1)
|
|
74
|
-
} else if (k === 'code') {
|
|
75
|
-
if (kind !== 'source') totals.testCode += 1
|
|
76
|
-
else {
|
|
77
|
-
totals.sourceCode += 1
|
|
78
|
-
if (tracked.has(file.path)) totals.trackedCode += 1
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const findings = slopFindings(file.after)
|
|
84
|
-
for (const f of findings) {
|
|
85
|
-
if (file.added.has(f.line)) add(f.rule, file.path, f.line)
|
|
86
|
-
else if (file.before != null) add('touched-file-slop', file.path, f.line, f.rule)
|
|
87
|
-
}
|
|
88
|
-
for (const block of commentBlocks(file.after)) {
|
|
89
|
-
let touched = false
|
|
90
|
-
for (let n = block.start; n <= block.end && !touched; n++) touched = file.added.has(n)
|
|
91
|
-
if (touched) for (const rule of block.rules) add(rule, file.path, block.start)
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const before = file.before == null ? { codeLines: 0, commentLines: 0 } : countLines(file.before)
|
|
95
|
-
const after = countLines(file.after)
|
|
96
|
-
if (kind === 'source') {
|
|
97
|
-
const density = c => (c.codeLines > 0 ? c.commentLines / c.codeLines : c.commentLines > 0 ? Infinity : 0)
|
|
98
|
-
if (file.before != null && after.commentLines > before.commentLines && density(after) > density(before)) {
|
|
99
|
-
add('touched-file-density', file.path, undefined, `${before.commentLines}/${before.codeLines} -> ${after.commentLines}/${after.codeLines}`)
|
|
100
|
-
}
|
|
101
|
-
continue
|
|
102
|
-
}
|
|
103
|
-
totals.testLines += after.codeLines - before.codeLines
|
|
104
|
-
const statsBefore = file.before == null ? EMPTY_STATS : testStats(file.path, file.before)
|
|
105
|
-
const statsAfter = testStats(file.path, file.after)
|
|
106
|
-
totals.cases += Math.max(0, statsAfter.cases - statsBefore.cases)
|
|
107
|
-
for (const [key, rule] of Object.entries(SUSPICIOUS)) {
|
|
108
|
-
const grew = statsAfter[key] - statsBefore[key]
|
|
109
|
-
if (grew > 0) add(rule, file.path, undefined, `+${grew}`)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const commentBudget = Math.max(limits.commentAllowance, Math.floor(limits.commentRatio * (totals.sourceCode + totals.testCode)))
|
|
114
|
-
const testedCode = totals.sourceCode + totals.trackedCode
|
|
115
|
-
const testBudget = Math.max(limits.testAllowance, Math.floor(limits.testRatio * testedCode))
|
|
116
|
-
const caseBudget = Math.max(limits.caseAllowance, Math.floor((limits.casesPerKloc * testedCode) / 1000))
|
|
117
|
-
if (totals.comments > commentBudget) {
|
|
118
|
-
const where = [...commentsByFile].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f, n]) => `${f} (${n})`).join(', ')
|
|
119
|
-
add('comment-budget', undefined, undefined, `${totals.comments} comment lines added, budget ${commentBudget}: ${where}`)
|
|
120
|
-
}
|
|
121
|
-
if (totals.testLines > testBudget) {
|
|
122
|
-
add('test-budget', undefined, undefined, `${totals.testLines} test lines added for ${totals.sourceCode} code lines, budget ${testBudget}`)
|
|
123
|
-
}
|
|
124
|
-
if (totals.cases > caseBudget) {
|
|
125
|
-
add('case-budget', undefined, undefined, `${totals.cases} test cases added for ${totals.sourceCode} code lines, budget ${caseBudget}`)
|
|
126
|
-
}
|
|
127
|
-
return { totals, budgets: { comments: commentBudget, testLines: testBudget, cases: caseBudget }, violations }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function addedLines(unifiedDiff) {
|
|
131
|
-
const added = new Set()
|
|
132
|
-
for (const m of unifiedDiff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
133
|
-
const start = Number(m[1])
|
|
134
|
-
const length = m[2] === undefined ? 1 : Number(m[2])
|
|
135
|
-
for (let n = start; n < start + length; n++) added.add(n)
|
|
136
|
-
}
|
|
137
|
-
return added
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function git(root, args) {
|
|
141
|
-
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function tryGit(root, args) {
|
|
145
|
-
try {
|
|
146
|
-
return git(root, args).trim()
|
|
147
|
-
} catch {
|
|
148
|
-
return null
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function resolveBase(root, requested) {
|
|
153
|
-
const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
|
|
154
|
-
for (const ref of candidates) {
|
|
155
|
-
const base = tryGit(root, ['merge-base', ref, 'HEAD'])
|
|
156
|
-
if (base) return { ref, base }
|
|
157
|
-
}
|
|
158
|
-
return null
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const normalize = text => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
162
|
-
|
|
163
|
-
function changes(root, base) {
|
|
164
|
-
const list = []
|
|
165
|
-
const entries = git(root, ['diff', '--name-status', '-M', '-z', base]).split('\0').filter(Boolean)
|
|
166
|
-
for (let i = 0; i < entries.length; ) {
|
|
167
|
-
const status = entries[i]
|
|
168
|
-
const renamed = status.startsWith('R') || status.startsWith('C')
|
|
169
|
-
list.push({ status, from: entries[i + 1], to: renamed ? entries[i + 2] : entries[i + 1] })
|
|
170
|
-
i += renamed ? 3 : 2
|
|
171
|
-
}
|
|
172
|
-
return list
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const untrackedFiles = root => git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)
|
|
176
|
-
|
|
177
|
-
export function collect(root, base) {
|
|
178
|
-
const files = []
|
|
179
|
-
for (const { status, from, to } of changes(root, base)) {
|
|
180
|
-
if (status === 'D' || !kindOf(to)) continue
|
|
181
|
-
const full = path.join(root, to)
|
|
182
|
-
if (!fs.existsSync(full)) continue
|
|
183
|
-
const after = normalize(fs.readFileSync(full, 'utf8'))
|
|
184
|
-
const before = status === 'A' ? null : normalize(git(root, ['show', `${base}:${from}`]))
|
|
185
|
-
const diff = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '-M', base, '--', ...new Set([from, to])])
|
|
186
|
-
files.push({ path: to, before, after, added: addedLines(diff) })
|
|
187
|
-
}
|
|
188
|
-
for (const untracked of untrackedFiles(root)) {
|
|
189
|
-
if (!kindOf(untracked)) continue
|
|
190
|
-
const after = normalize(fs.readFileSync(path.join(root, untracked), 'utf8'))
|
|
191
|
-
const count = after.split('\n').length - (after.endsWith('\n') ? 1 : 0)
|
|
192
|
-
files.push({ path: untracked, before: null, after, added: new Set(Array.from({ length: count }, (_, k) => k + 1)) })
|
|
193
|
-
}
|
|
194
|
-
return files
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// A subagent checks only the files it owns while others edit the same tree: the budgets then fit its share.
|
|
198
|
-
export function scope(files, paths) {
|
|
199
|
-
if (!paths?.length) return files
|
|
200
|
-
const norm = paths.map(p => p.replace(/\\/g, '/').replace(/^\.\//, ''))
|
|
201
|
-
return files.filter(f => norm.some(p => f.path === p || f.path.startsWith(p.endsWith('/') ? p : `${p}/`)))
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Paths of the files a complexity gate tracks, from its baseline keyed `path::method`.
|
|
205
|
-
export function trackedFiles(root, baseline) {
|
|
206
|
-
try {
|
|
207
|
-
const entries = Object.keys(JSON.parse(fs.readFileSync(path.join(root, baseline), 'utf8')))
|
|
208
|
-
return new Set(entries.map(key => key.split('::')[0]))
|
|
209
|
-
} catch {
|
|
210
|
-
return new Set()
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// A diff that changes the sync marker is a template sync, the one change allowed to rewrite owned files.
|
|
215
|
-
export function platformViolations(root, base, paths, contract) {
|
|
216
|
-
if (isTemplateSource(root, contract)) return []
|
|
217
|
-
const touched = new Set(untrackedFiles(root))
|
|
218
|
-
for (const { status, from, to } of changes(root, base)) {
|
|
219
|
-
touched.add(to)
|
|
220
|
-
if (status.startsWith('R')) touched.add(from)
|
|
221
|
-
}
|
|
222
|
-
if (contract.sync.marker && touched.has(contract.sync.marker)) return []
|
|
223
|
-
const { owner, fix } = describeOwner(contract)
|
|
224
|
-
return scope([...touched].map(file => ({ path: file })), paths)
|
|
225
|
-
.filter(file => isOwned(contract, file.path))
|
|
226
|
-
.map(file => ({ rule: 'platform-file', file: file.path, detail: owner, fix }))
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export function reuseWarnings(root, base, paths, contract) {
|
|
230
|
-
if (!contract.reuse.packages.length && !contract.reuse.names.length) return []
|
|
231
|
-
const added = [...untrackedFiles(root), ...changes(root, base).filter(c => c.status === 'A').map(c => c.to)]
|
|
232
|
-
const files = scope(added.map(file => ({ path: file })), paths).filter(f => kindOf(f.path) === 'source' && !isOwned(contract, f.path))
|
|
233
|
-
if (!files.length) return []
|
|
234
|
-
const names = reusableNames(root, contract, files.map(f => f.path))
|
|
235
|
-
return files.flatMap(file => {
|
|
236
|
-
const match = names.get(nameKey(path.basename(file.path).replace(/\.[^.]+$/, '')))
|
|
237
|
-
return match ? [{ rule: 'reuse-shadow', file: file.path, detail: `${match.name} from ${match.origin}`, fix: FIX['reuse-shadow'] }] : []
|
|
238
|
-
})
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function platformFindings(root, base, paths) {
|
|
242
|
-
try {
|
|
243
|
-
const contract = loadContract(root)
|
|
244
|
-
if (!contract) return { violations: [], warnings: [] }
|
|
245
|
-
return { violations: platformViolations(root, base, paths, contract), warnings: reuseWarnings(root, base, paths, contract) }
|
|
246
|
-
} catch (error) {
|
|
247
|
-
if (!(error instanceof ContractError)) throw error
|
|
248
|
-
return { violations: [{ rule: 'platform-contract', detail: error.message, fix: FIX['platform-contract'] }], warnings: [] }
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function readLimits(root) {
|
|
253
|
-
try {
|
|
254
|
-
const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'harness.json'), 'utf8'))
|
|
255
|
-
return { ...DEFAULT_LIMITS, ...config.codeRules }
|
|
256
|
-
} catch {
|
|
257
|
-
return DEFAULT_LIMITS
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function report(result, base) {
|
|
262
|
-
const { totals, budgets, violations } = result
|
|
263
|
-
const lines = [
|
|
264
|
-
`check-diff against ${base.ref} (${base.base.slice(0, 7)}): ${violations.length === 0 ? 'clean' : `${violations.length} problem${violations.length === 1 ? '' : 's'}`}`,
|
|
265
|
-
` code lines added ${totals.sourceCode}, comment lines ${totals.comments}/${budgets.comments}, test lines ${totals.testLines}/${budgets.testLines}, test cases ${totals.cases}/${budgets.cases}`,
|
|
266
|
-
]
|
|
267
|
-
const byRule = new Map()
|
|
268
|
-
for (const v of [...violations, ...(result.warnings ?? [])]) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
|
|
269
|
-
for (const [rule, list] of byRule) {
|
|
270
|
-
lines.push('', `${rule}${violations.includes(list[0]) ? '' : ' (warning)'}: ${list[0].fix ?? FIX[rule]}`)
|
|
271
|
-
for (const v of list.slice(0, 50)) {
|
|
272
|
-
const where = v.file ? `${v.file}${v.line ? `:${v.line}` : ''}` : ''
|
|
273
|
-
lines.push(` ${[where, v.detail].filter(Boolean).join(' ')}`)
|
|
274
|
-
}
|
|
275
|
-
if (list.length > 50) lines.push(` ... ${list.length - 50} more`)
|
|
276
|
-
}
|
|
277
|
-
return lines.join('\n')
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function main(argv) {
|
|
281
|
-
const option = name => {
|
|
282
|
-
const i = argv.indexOf(`--${name}`)
|
|
283
|
-
return i === -1 ? undefined : argv[i + 1]
|
|
284
|
-
}
|
|
285
|
-
const root = tryGit(process.cwd(), ['rev-parse', '--show-toplevel'])
|
|
286
|
-
if (!root) {
|
|
287
|
-
console.error('check-diff: not inside a git repository.')
|
|
288
|
-
return 2
|
|
289
|
-
}
|
|
290
|
-
const base = resolveBase(root, option('base') ?? process.env.CHECK_DIFF_BASE)
|
|
291
|
-
if (!base) {
|
|
292
|
-
console.error('check-diff: no base to compare against. Pass --base <ref> or fetch origin.')
|
|
293
|
-
return 2
|
|
294
|
-
}
|
|
295
|
-
const limits = readLimits(root)
|
|
296
|
-
const paths = option('paths')?.split(',').map(p => p.trim()).filter(Boolean)
|
|
297
|
-
const result = evaluate(scope(collect(root, base.base), paths), limits, trackedFiles(root, limits.complexityBaseline))
|
|
298
|
-
const platform = platformFindings(root, base.base, paths)
|
|
299
|
-
result.violations.push(...platform.violations)
|
|
300
|
-
if (platform.warnings.length) result.warnings = platform.warnings
|
|
301
|
-
console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
|
|
302
|
-
return result.violations.length === 0 ? 0 : 1
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exitCode = main(process.argv.slice(2))
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import { commentBlocks, countLines, isGenerated, kindOf, lineKinds, slopFindings, testStats } from './code-rules.mjs'
|
|
6
|
+
import { ContractError, describeOwner, isOwned, isTemplateSource, loadContract, nameKey, reusableNames } from './platform-contract.mjs'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LIMITS = {
|
|
9
|
+
commentRatio: 0.1,
|
|
10
|
+
commentAllowance: 2,
|
|
11
|
+
testRatio: 1,
|
|
12
|
+
testAllowance: 40,
|
|
13
|
+
casesPerKloc: 40,
|
|
14
|
+
caseAllowance: 3,
|
|
15
|
+
complexityBaseline: 'scripts/complexity-baseline.json',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const FIX = {
|
|
19
|
+
'comment-budget': 'Delete comments until the diff is within budget. Keep only one that explains a constraint the code cannot show: a third-party bug, an external contract, a legal requirement.',
|
|
20
|
+
'banner': 'Delete the separator.',
|
|
21
|
+
'region': 'Delete the region markers.',
|
|
22
|
+
'untracked-todo': 'Link an issue or delete the TODO.',
|
|
23
|
+
'restating-summary': 'Delete the doc comment: it repeats the name.',
|
|
24
|
+
'restating-jsdoc': 'Delete the doc comment: it repeats the name.',
|
|
25
|
+
'restating-param': 'Delete the parameter doc: it repeats the name.',
|
|
26
|
+
'filler-returns': 'Delete the returns doc: it says nothing the signature does not.',
|
|
27
|
+
'commented-out-code': 'Delete it; git keeps the history.',
|
|
28
|
+
'apologetic': 'Make the code right or delete the note.',
|
|
29
|
+
'history': 'Delete the incident story; it belongs in the commit message or the pull request.',
|
|
30
|
+
'essay': 'Cut the block to one line or delete it; long explanations belong in docs or the pull request.',
|
|
31
|
+
'internal-doc': 'Delete the doc comment: nothing outside the file calls this.',
|
|
32
|
+
'touched-file-slop': 'This file is in the diff, so leave it clean: delete the finding even though it predates the change.',
|
|
33
|
+
'touched-file-density': 'This file now has more comments per code line than before. Delete comments until it does not.',
|
|
34
|
+
'test-budget': 'Too much test code for the change. Keep the tests of the public contract; a bug fix gets one regression test.',
|
|
35
|
+
'case-budget': 'Too many test cases for the change. Merge or delete the ones that test internals or repeat each other.',
|
|
36
|
+
'test-skipped': 'Delete the skipped test or make it run.',
|
|
37
|
+
'test-focused': 'Remove .only / f-prefix: it hides every other test.',
|
|
38
|
+
'test-snapshot': 'Assert the values that matter instead of a snapshot.',
|
|
39
|
+
'test-no-assertion': 'Assert something, or delete the test.',
|
|
40
|
+
'test-mock-only': 'Assert on state or output, not only that a mock was called.',
|
|
41
|
+
'platform-file': 'This file is owned by the template named in the platform contract. Ask for the change there, or add a dated exception.',
|
|
42
|
+
'platform-contract': 'Fix the contract: until it reads, no platform file can be checked.',
|
|
43
|
+
'reuse-shadow': 'The platform contract marks this name for reuse. Use the existing one, or rename the file if it is something else.',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUSPICIOUS = {
|
|
47
|
+
skipped: 'test-skipped',
|
|
48
|
+
focused: 'test-focused',
|
|
49
|
+
snapshot: 'test-snapshot',
|
|
50
|
+
noAssertion: 'test-no-assertion',
|
|
51
|
+
mockOnly: 'test-mock-only',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EMPTY_STATS = { cases: 0, skipped: 0, focused: 0, snapshot: 0, noAssertion: 0, mockOnly: 0 }
|
|
55
|
+
|
|
56
|
+
// `tracked` holds the files a complexity gate has methods on record for. Their new lines buy twice
|
|
57
|
+
// the test budget: that gate fails a method whose coverage drops, so a change there needs its new
|
|
58
|
+
// branches tested, and trimming those tests to fit this budget is what broke Pliny-Bot #633.
|
|
59
|
+
export function evaluate(files, limits = DEFAULT_LIMITS, tracked = new Set()) {
|
|
60
|
+
const violations = []
|
|
61
|
+
const add = (rule, file, line, detail) => violations.push({ rule, file, line, detail, fix: FIX[rule] })
|
|
62
|
+
const totals = { sourceCode: 0, trackedCode: 0, testCode: 0, comments: 0, testLines: 0, cases: 0 }
|
|
63
|
+
const commentsByFile = new Map()
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
const kind = kindOf(file.path)
|
|
67
|
+
if (!kind || isGenerated(file.after)) continue
|
|
68
|
+
const kinds = lineKinds(file.after)
|
|
69
|
+
for (const n of file.added) {
|
|
70
|
+
const k = kinds[n - 1]
|
|
71
|
+
if (k === 'comment') {
|
|
72
|
+
totals.comments += 1
|
|
73
|
+
commentsByFile.set(file.path, (commentsByFile.get(file.path) ?? 0) + 1)
|
|
74
|
+
} else if (k === 'code') {
|
|
75
|
+
if (kind !== 'source') totals.testCode += 1
|
|
76
|
+
else {
|
|
77
|
+
totals.sourceCode += 1
|
|
78
|
+
if (tracked.has(file.path)) totals.trackedCode += 1
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const findings = slopFindings(file.after)
|
|
84
|
+
for (const f of findings) {
|
|
85
|
+
if (file.added.has(f.line)) add(f.rule, file.path, f.line)
|
|
86
|
+
else if (file.before != null) add('touched-file-slop', file.path, f.line, f.rule)
|
|
87
|
+
}
|
|
88
|
+
for (const block of commentBlocks(file.after)) {
|
|
89
|
+
let touched = false
|
|
90
|
+
for (let n = block.start; n <= block.end && !touched; n++) touched = file.added.has(n)
|
|
91
|
+
if (touched) for (const rule of block.rules) add(rule, file.path, block.start)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const before = file.before == null ? { codeLines: 0, commentLines: 0 } : countLines(file.before)
|
|
95
|
+
const after = countLines(file.after)
|
|
96
|
+
if (kind === 'source') {
|
|
97
|
+
const density = c => (c.codeLines > 0 ? c.commentLines / c.codeLines : c.commentLines > 0 ? Infinity : 0)
|
|
98
|
+
if (file.before != null && after.commentLines > before.commentLines && density(after) > density(before)) {
|
|
99
|
+
add('touched-file-density', file.path, undefined, `${before.commentLines}/${before.codeLines} -> ${after.commentLines}/${after.codeLines}`)
|
|
100
|
+
}
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
totals.testLines += after.codeLines - before.codeLines
|
|
104
|
+
const statsBefore = file.before == null ? EMPTY_STATS : testStats(file.path, file.before)
|
|
105
|
+
const statsAfter = testStats(file.path, file.after)
|
|
106
|
+
totals.cases += Math.max(0, statsAfter.cases - statsBefore.cases)
|
|
107
|
+
for (const [key, rule] of Object.entries(SUSPICIOUS)) {
|
|
108
|
+
const grew = statsAfter[key] - statsBefore[key]
|
|
109
|
+
if (grew > 0) add(rule, file.path, undefined, `+${grew}`)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const commentBudget = Math.max(limits.commentAllowance, Math.floor(limits.commentRatio * (totals.sourceCode + totals.testCode)))
|
|
114
|
+
const testedCode = totals.sourceCode + totals.trackedCode
|
|
115
|
+
const testBudget = Math.max(limits.testAllowance, Math.floor(limits.testRatio * testedCode))
|
|
116
|
+
const caseBudget = Math.max(limits.caseAllowance, Math.floor((limits.casesPerKloc * testedCode) / 1000))
|
|
117
|
+
if (totals.comments > commentBudget) {
|
|
118
|
+
const where = [...commentsByFile].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([f, n]) => `${f} (${n})`).join(', ')
|
|
119
|
+
add('comment-budget', undefined, undefined, `${totals.comments} comment lines added, budget ${commentBudget}: ${where}`)
|
|
120
|
+
}
|
|
121
|
+
if (totals.testLines > testBudget) {
|
|
122
|
+
add('test-budget', undefined, undefined, `${totals.testLines} test lines added for ${totals.sourceCode} code lines, budget ${testBudget}`)
|
|
123
|
+
}
|
|
124
|
+
if (totals.cases > caseBudget) {
|
|
125
|
+
add('case-budget', undefined, undefined, `${totals.cases} test cases added for ${totals.sourceCode} code lines, budget ${caseBudget}`)
|
|
126
|
+
}
|
|
127
|
+
return { totals, budgets: { comments: commentBudget, testLines: testBudget, cases: caseBudget }, violations }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function addedLines(unifiedDiff) {
|
|
131
|
+
const added = new Set()
|
|
132
|
+
for (const m of unifiedDiff.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
133
|
+
const start = Number(m[1])
|
|
134
|
+
const length = m[2] === undefined ? 1 : Number(m[2])
|
|
135
|
+
for (let n = start; n < start + length; n++) added.add(n)
|
|
136
|
+
}
|
|
137
|
+
return added
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function git(root, args) {
|
|
141
|
+
return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 1 << 28, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function tryGit(root, args) {
|
|
145
|
+
try {
|
|
146
|
+
return git(root, args).trim()
|
|
147
|
+
} catch {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function resolveBase(root, requested) {
|
|
153
|
+
const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
|
|
154
|
+
for (const ref of candidates) {
|
|
155
|
+
const base = tryGit(root, ['merge-base', ref, 'HEAD'])
|
|
156
|
+
if (base) return { ref, base }
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const normalize = text => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
|
|
162
|
+
|
|
163
|
+
function changes(root, base) {
|
|
164
|
+
const list = []
|
|
165
|
+
const entries = git(root, ['diff', '--name-status', '-M', '-z', base]).split('\0').filter(Boolean)
|
|
166
|
+
for (let i = 0; i < entries.length; ) {
|
|
167
|
+
const status = entries[i]
|
|
168
|
+
const renamed = status.startsWith('R') || status.startsWith('C')
|
|
169
|
+
list.push({ status, from: entries[i + 1], to: renamed ? entries[i + 2] : entries[i + 1] })
|
|
170
|
+
i += renamed ? 3 : 2
|
|
171
|
+
}
|
|
172
|
+
return list
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const untrackedFiles = root => git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)
|
|
176
|
+
|
|
177
|
+
export function collect(root, base) {
|
|
178
|
+
const files = []
|
|
179
|
+
for (const { status, from, to } of changes(root, base)) {
|
|
180
|
+
if (status === 'D' || !kindOf(to)) continue
|
|
181
|
+
const full = path.join(root, to)
|
|
182
|
+
if (!fs.existsSync(full)) continue
|
|
183
|
+
const after = normalize(fs.readFileSync(full, 'utf8'))
|
|
184
|
+
const before = status === 'A' ? null : normalize(git(root, ['show', `${base}:${from}`]))
|
|
185
|
+
const diff = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '-M', base, '--', ...new Set([from, to])])
|
|
186
|
+
files.push({ path: to, before, after, added: addedLines(diff) })
|
|
187
|
+
}
|
|
188
|
+
for (const untracked of untrackedFiles(root)) {
|
|
189
|
+
if (!kindOf(untracked)) continue
|
|
190
|
+
const after = normalize(fs.readFileSync(path.join(root, untracked), 'utf8'))
|
|
191
|
+
const count = after.split('\n').length - (after.endsWith('\n') ? 1 : 0)
|
|
192
|
+
files.push({ path: untracked, before: null, after, added: new Set(Array.from({ length: count }, (_, k) => k + 1)) })
|
|
193
|
+
}
|
|
194
|
+
return files
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A subagent checks only the files it owns while others edit the same tree: the budgets then fit its share.
|
|
198
|
+
export function scope(files, paths) {
|
|
199
|
+
if (!paths?.length) return files
|
|
200
|
+
const norm = paths.map(p => p.replace(/\\/g, '/').replace(/^\.\//, ''))
|
|
201
|
+
return files.filter(f => norm.some(p => f.path === p || f.path.startsWith(p.endsWith('/') ? p : `${p}/`)))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Paths of the files a complexity gate tracks, from its baseline keyed `path::method`.
|
|
205
|
+
export function trackedFiles(root, baseline) {
|
|
206
|
+
try {
|
|
207
|
+
const entries = Object.keys(JSON.parse(fs.readFileSync(path.join(root, baseline), 'utf8')))
|
|
208
|
+
return new Set(entries.map(key => key.split('::')[0]))
|
|
209
|
+
} catch {
|
|
210
|
+
return new Set()
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// A diff that changes the sync marker is a template sync, the one change allowed to rewrite owned files.
|
|
215
|
+
export function platformViolations(root, base, paths, contract) {
|
|
216
|
+
if (isTemplateSource(root, contract)) return []
|
|
217
|
+
const touched = new Set(untrackedFiles(root))
|
|
218
|
+
for (const { status, from, to } of changes(root, base)) {
|
|
219
|
+
touched.add(to)
|
|
220
|
+
if (status.startsWith('R')) touched.add(from)
|
|
221
|
+
}
|
|
222
|
+
if (contract.sync.marker && touched.has(contract.sync.marker)) return []
|
|
223
|
+
const { owner, fix } = describeOwner(contract)
|
|
224
|
+
return scope([...touched].map(file => ({ path: file })), paths)
|
|
225
|
+
.filter(file => isOwned(contract, file.path))
|
|
226
|
+
.map(file => ({ rule: 'platform-file', file: file.path, detail: owner, fix }))
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function reuseWarnings(root, base, paths, contract) {
|
|
230
|
+
if (!contract.reuse.packages.length && !contract.reuse.names.length) return []
|
|
231
|
+
const added = [...untrackedFiles(root), ...changes(root, base).filter(c => c.status === 'A').map(c => c.to)]
|
|
232
|
+
const files = scope(added.map(file => ({ path: file })), paths).filter(f => kindOf(f.path) === 'source' && !isOwned(contract, f.path))
|
|
233
|
+
if (!files.length) return []
|
|
234
|
+
const names = reusableNames(root, contract, files.map(f => f.path))
|
|
235
|
+
return files.flatMap(file => {
|
|
236
|
+
const match = names.get(nameKey(path.basename(file.path).replace(/\.[^.]+$/, '')))
|
|
237
|
+
return match ? [{ rule: 'reuse-shadow', file: file.path, detail: `${match.name} from ${match.origin}`, fix: FIX['reuse-shadow'] }] : []
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function platformFindings(root, base, paths) {
|
|
242
|
+
try {
|
|
243
|
+
const contract = loadContract(root)
|
|
244
|
+
if (!contract) return { violations: [], warnings: [] }
|
|
245
|
+
return { violations: platformViolations(root, base, paths, contract), warnings: reuseWarnings(root, base, paths, contract) }
|
|
246
|
+
} catch (error) {
|
|
247
|
+
if (!(error instanceof ContractError)) throw error
|
|
248
|
+
return { violations: [{ rule: 'platform-contract', detail: error.message, fix: FIX['platform-contract'] }], warnings: [] }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readLimits(root) {
|
|
253
|
+
try {
|
|
254
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, '.opencode', 'harness.json'), 'utf8'))
|
|
255
|
+
return { ...DEFAULT_LIMITS, ...config.codeRules }
|
|
256
|
+
} catch {
|
|
257
|
+
return DEFAULT_LIMITS
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function report(result, base) {
|
|
262
|
+
const { totals, budgets, violations } = result
|
|
263
|
+
const lines = [
|
|
264
|
+
`check-diff against ${base.ref} (${base.base.slice(0, 7)}): ${violations.length === 0 ? 'clean' : `${violations.length} problem${violations.length === 1 ? '' : 's'}`}`,
|
|
265
|
+
` code lines added ${totals.sourceCode}, comment lines ${totals.comments}/${budgets.comments}, test lines ${totals.testLines}/${budgets.testLines}, test cases ${totals.cases}/${budgets.cases}`,
|
|
266
|
+
]
|
|
267
|
+
const byRule = new Map()
|
|
268
|
+
for (const v of [...violations, ...(result.warnings ?? [])]) byRule.set(v.rule, [...(byRule.get(v.rule) ?? []), v])
|
|
269
|
+
for (const [rule, list] of byRule) {
|
|
270
|
+
lines.push('', `${rule}${violations.includes(list[0]) ? '' : ' (warning)'}: ${list[0].fix ?? FIX[rule]}`)
|
|
271
|
+
for (const v of list.slice(0, 50)) {
|
|
272
|
+
const where = v.file ? `${v.file}${v.line ? `:${v.line}` : ''}` : ''
|
|
273
|
+
lines.push(` ${[where, v.detail].filter(Boolean).join(' ')}`)
|
|
274
|
+
}
|
|
275
|
+
if (list.length > 50) lines.push(` ... ${list.length - 50} more`)
|
|
276
|
+
}
|
|
277
|
+
return lines.join('\n')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function main(argv) {
|
|
281
|
+
const option = name => {
|
|
282
|
+
const i = argv.indexOf(`--${name}`)
|
|
283
|
+
return i === -1 ? undefined : argv[i + 1]
|
|
284
|
+
}
|
|
285
|
+
const root = tryGit(process.cwd(), ['rev-parse', '--show-toplevel'])
|
|
286
|
+
if (!root) {
|
|
287
|
+
console.error('check-diff: not inside a git repository.')
|
|
288
|
+
return 2
|
|
289
|
+
}
|
|
290
|
+
const base = resolveBase(root, option('base') ?? process.env.CHECK_DIFF_BASE)
|
|
291
|
+
if (!base) {
|
|
292
|
+
console.error('check-diff: no base to compare against. Pass --base <ref> or fetch origin.')
|
|
293
|
+
return 2
|
|
294
|
+
}
|
|
295
|
+
const limits = readLimits(root)
|
|
296
|
+
const paths = option('paths')?.split(',').map(p => p.trim()).filter(Boolean)
|
|
297
|
+
const result = evaluate(scope(collect(root, base.base), paths), limits, trackedFiles(root, limits.complexityBaseline))
|
|
298
|
+
const platform = platformFindings(root, base.base, paths)
|
|
299
|
+
result.violations.push(...platform.violations)
|
|
300
|
+
if (platform.warnings.length) result.warnings = platform.warnings
|
|
301
|
+
console.log(argv.includes('--json') ? JSON.stringify({ base, ...result }, null, 2) : report(result, base))
|
|
302
|
+
return result.violations.length === 0 ? 0 : 1
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exitCode = main(process.argv.slice(2))
|