@erclx/canon 4.71.0 → 4.72.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.
@@ -14,6 +14,12 @@ import {
14
14
  planCitations,
15
15
  } from '@/tasks/archive'
16
16
  import { type LabelOutcome, nextLabel } from '@/tasks/label'
17
+ import {
18
+ type Claim,
19
+ type Holder,
20
+ planReach,
21
+ type ReachOutcome,
22
+ } from '@/tasks/reach'
17
23
  import {
18
24
  type CloseOutcome,
19
25
  closeOutcomes,
@@ -84,6 +90,12 @@ interface BranchCommandOptions {
84
90
  readonly root?: string
85
91
  }
86
92
 
93
+ interface ReachCommandOptions {
94
+ readonly base?: string
95
+ readonly json?: boolean
96
+ readonly root?: string
97
+ }
98
+
87
99
  interface PullRequestCommandOptions {
88
100
  readonly json?: boolean
89
101
  readonly plan?: string
@@ -326,6 +338,45 @@ export function register(program: Command): void {
326
338
  process.exitCode = await runBranch(plan, opts)
327
339
  })
328
340
 
341
+ tasks
342
+ .command('plan-reach')
343
+ .description('Read a branch back against what was declared about it')
344
+ .argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
345
+ .helpOption('-h, --help', 'Show this help message')
346
+ .option('--base <ref>', 'Far side of the range, defaulting to the trunk')
347
+ .option('--json', 'Emit a machine-readable record on stdout')
348
+ .option('--root <path>', 'Board root, defaulting to the main worktree')
349
+ .addHelpText(
350
+ 'after',
351
+ [
352
+ '',
353
+ 'Exit codes:',
354
+ ' 0 the reach was read and no other track claims a changed path',
355
+ ' 1 refused as no-plan, archived, bad-input, no-base, or no-diff',
356
+ ' 2 read, and another live plan or Run now row claims a path',
357
+ '',
358
+ 'It reports claimed first and undeclared second. A claimed path is one',
359
+ 'another track holds and is the half worth acting on; an undeclared one',
360
+ 'is a path this plan never named, which the ship chain writes on nearly',
361
+ 'every branch. It reports and never gates, so branch on the record',
362
+ 'rather than on the exit code, which a shell function wrapping canon',
363
+ 'can flatten to zero.',
364
+ '',
365
+ 'The range is read at the current directory and the plans and board at',
366
+ 'the board root, so a linked worktree reads its own branch against the',
367
+ 'shared records. It reads only what is written down, so the plan and',
368
+ 'row counts say how much was there to compare against.',
369
+ '',
370
+ 'Examples:',
371
+ ' canon tasks plan-reach dispatch-answer-gate',
372
+ ' canon tasks plan-reach dispatch-answer-gate --base origin/main --json',
373
+ '',
374
+ ].join('\n'),
375
+ )
376
+ .action(async (plan: string, opts: ReachCommandOptions) => {
377
+ process.exitCode = await runReach(plan, opts)
378
+ })
379
+
329
380
  tasks
330
381
  .command('pull-request')
331
382
  .description('Record a pull request number on the task a branch closes')
@@ -961,6 +1012,109 @@ function reportBranch(
961
1012
  return EXIT_FINDINGS
962
1013
  }
963
1014
 
1015
+ async function runReach(
1016
+ plan: string,
1017
+ opts: ReachCommandOptions,
1018
+ ): Promise<number> {
1019
+ const root = opts.root ?? (await mainWorktreeRoot())
1020
+ // The range belongs to the checkout the caller stands in, which is the linked
1021
+ // worktree holding the branch on every dispatched run. The board root is the
1022
+ // main worktree, where reading a range measures the trunk against itself.
1023
+ const outcome = await planReach(root, plan, {
1024
+ repo: process.cwd(),
1025
+ ref: opts.base,
1026
+ })
1027
+
1028
+ return reportReach(outcome, opts.json ?? false, root)
1029
+ }
1030
+
1031
+ /**
1032
+ * Names a holder, the declaration it matched on when that is wider than the
1033
+ * path, and whether a holding plan carries a dispatch row. A plan with no row
1034
+ * is the shape a plan nobody archived takes, which is what sends a reader to
1035
+ * check the holder rather than treating the claim as a live collision.
1036
+ */
1037
+ function describeHolder(claim: Claim, holder: Holder): string {
1038
+ const under =
1039
+ holder.declaration === claim.path ? '' : `, under ${holder.declaration}`
1040
+ const rowed = holder.source === 'plan' && !holder.rowed ? ', no row' : ''
1041
+
1042
+ return `${holder.name} (${holder.source}${under}${rowed})`
1043
+ }
1044
+
1045
+ function describeReachClaim(claim: Claim): string {
1046
+ const holders = claim.holders
1047
+ .map((holder) => describeHolder(claim, holder))
1048
+ .join(' and ')
1049
+
1050
+ return `${claim.path} is held by ${holders}`
1051
+ }
1052
+
1053
+ /**
1054
+ * Leads with the claimed list because it is the short one. Over the wave this
1055
+ * verb was filed against, undeclared paths ran 22 of 26 on one branch and 18 of
1056
+ * 25 on another, so a report opening on that list is noise a reader skips past
1057
+ * and the crossing underneath it goes with them.
1058
+ */
1059
+ function reportReach(
1060
+ outcome: ReachOutcome,
1061
+ emitJson: boolean,
1062
+ root: string,
1063
+ ): number {
1064
+ if (!outcome.ok) {
1065
+ if (emitJson) {
1066
+ process.stdout.write(
1067
+ `${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
1068
+ )
1069
+ return 1
1070
+ }
1071
+
1072
+ intro('canon tasks plan-reach')
1073
+ logStep('Refused')
1074
+ logError(outcome.message)
1075
+ outro()
1076
+ return 1
1077
+ }
1078
+
1079
+ if (emitJson) {
1080
+ process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
1081
+ return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
1082
+ }
1083
+
1084
+ intro('canon tasks plan-reach')
1085
+
1086
+ logStep(outcome.claimed.length === 0 ? 'Uncontested' : 'Claimed')
1087
+ if (outcome.claimed.length === 0) {
1088
+ logInfo('no other live plan or Run now row holds a path this branch wrote')
1089
+ } else {
1090
+ for (const claim of outcome.claimed) logWarn(describeReachClaim(claim))
1091
+ }
1092
+
1093
+ logStep('Undeclared')
1094
+ if (outcome.undeclared.length === 0) {
1095
+ logInfo(
1096
+ `${outcome.plan} declared every one of the ${outcome.changed} paths`,
1097
+ )
1098
+ } else {
1099
+ for (const path of outcome.undeclared) logInfo(path)
1100
+ logInfo(
1101
+ `${outcome.undeclared.length} of ${outcome.changed} path(s) this plan did not name, which the ship chain's own steps account for on most branches`,
1102
+ )
1103
+ }
1104
+
1105
+ // The counts are the report's own bound. A verb reading what is written down
1106
+ // sees no hand-launched track and no track without a plan, so a clear reading
1107
+ // over nothing compared against would otherwise read as a proof.
1108
+ logStep('Compared against')
1109
+ logInfo(
1110
+ `${outcome.plans} other live plan(s) and ${outcome.rows} Run now row(s)${outcome.board ? '' : ', with no board on disk to read'}`,
1111
+ )
1112
+
1113
+ outro()
1114
+
1115
+ return outcome.claimed.length === 0 ? 0 : EXIT_FINDINGS
1116
+ }
1117
+
964
1118
  function reportValidation(
965
1119
  outcome: ValidateOutcome,
966
1120
  emitJson: boolean,
package/src/git-files.ts CHANGED
@@ -68,6 +68,75 @@ export async function listChangedFiles(
68
68
  return [...new Set(paths.filter(Boolean))].sort()
69
69
  }
70
70
 
71
+ /** A file's old and new path across a git-detected rename. */
72
+ export interface RenamePair {
73
+ readonly from: string
74
+ readonly to: string
75
+ }
76
+
77
+ /**
78
+ * Every rename `base..working tree` carries, read through the same similarity
79
+ * detection `git diff -M` applies by default (50%).
80
+ *
81
+ * A move heavy enough on rewritten content to fall under that floor reports
82
+ * as a plain delete and add rather than a rename, which is not a defect this
83
+ * reads around: the diff genuinely does not carry the file a claim citing the
84
+ * old path describes.
85
+ *
86
+ * Returns undefined when git cannot answer, matching `listChangedFiles`.
87
+ */
88
+ export async function listRenames(
89
+ root: string,
90
+ base: string,
91
+ ): Promise<RenamePair[] | undefined> {
92
+ const output = await git(root, ['diff', '--name-status', '-M', base])
93
+ if (output === undefined) return undefined
94
+
95
+ const renames: RenamePair[] = []
96
+ for (const line of output.split('\n')) {
97
+ if (line === '') continue
98
+ const [status, from, to] = line.split('\t')
99
+ if (status === undefined || !status.startsWith('R')) continue
100
+ if (from === undefined || to === undefined) continue
101
+ renames.push({ from, to })
102
+ }
103
+ return renames
104
+ }
105
+
106
+ /**
107
+ * Added, non-comment lines from a unified diff hunk touching `.gitignore`.
108
+ *
109
+ * Shared between a local `git diff` and a patch string GitHub returns inline
110
+ * for the same file, so the two evidence sources read one pattern the same
111
+ * way.
112
+ */
113
+ export function parseIgnoreAdditions(diffText: string): string[] {
114
+ const patterns: string[] = []
115
+ for (const line of diffText.split('\n')) {
116
+ if (!line.startsWith('+') || line.startsWith('+++')) continue
117
+ const pattern = line.slice(1).trim()
118
+ if (pattern === '' || pattern.startsWith('#')) continue
119
+ patterns.push(pattern)
120
+ }
121
+ return patterns
122
+ }
123
+
124
+ /**
125
+ * Every pattern a branch added to `.gitignore` since `base`.
126
+ *
127
+ * `-U0` keeps the hunk to changed lines alone, which is all a claim needs.
128
+ *
129
+ * Returns undefined when git cannot answer, matching `listChangedFiles`.
130
+ */
131
+ export async function listIgnoreAdditions(
132
+ root: string,
133
+ base: string,
134
+ ): Promise<string[] | undefined> {
135
+ const output = await git(root, ['diff', '-U0', base, '--', '.gitignore'])
136
+ if (output === undefined) return undefined
137
+ return parseIgnoreAdditions(output)
138
+ }
139
+
71
140
  /**
72
141
  * Lists the files under `root`: tracked, plus untracked files git does not
73
142
  * ignore. The untracked half is what keeps a file added on this branch in scope
@@ -1,3 +1,4 @@
1
+ import type { RenamePair } from '@/git-files'
1
2
  import { extractKeyChangePaths, KEY_CHANGES, type PathClaim } from '@/pr/paths'
2
3
 
3
4
  /**
@@ -53,6 +54,14 @@ export interface BijectionReport {
53
54
  * branch author.
54
55
  */
55
56
  readonly incidental: readonly string[]
57
+ /**
58
+ * True when the rename or `.gitignore`-addition evidence could not be read.
59
+ *
60
+ * A claim `unmet` would otherwise carry is downgraded to `unresolved`
61
+ * instead, since the unread evidence might have credited it and this
62
+ * comparison has no way to tell a stale claim from one it could not check.
63
+ */
64
+ readonly evidenceUnread: boolean
56
65
  }
57
66
 
58
67
  export type Bijection =
@@ -66,6 +75,20 @@ export interface BijectionInput {
66
75
  readonly roots: ReadonlySet<string>
67
76
  readonly head?: string
68
77
  readonly title?: string
78
+ /** A rename's source path credits a claim naming it, without ever counting as a changed file. */
79
+ readonly renames?: readonly RenamePair[]
80
+ /** A pattern newly added to `.gitignore`, crediting a claim naming it as the `.gitignore` change it is. */
81
+ readonly ignoreAdditions?: readonly string[]
82
+ /**
83
+ * True when the caller could not read the rename or ignore-addition
84
+ * evidence, rather than reading it and finding neither.
85
+ *
86
+ * The two states produce the same empty `renames`/`ignoreAdditions`, so
87
+ * this is the only way `compareKeyChanges` can tell a claim that is
88
+ * genuinely stale from one the evidence might have credited had the read
89
+ * succeeded.
90
+ */
91
+ readonly evidenceUnread?: boolean
69
92
  }
70
93
 
71
94
  /**
@@ -94,6 +117,28 @@ function owesNoBullet(path: string): boolean {
94
117
  return INCIDENTAL.some((pattern) => pattern.test(path))
95
118
  }
96
119
 
120
+ /** Drops a directory claim's trailing slash so it compares against a raw `.gitignore` pattern. */
121
+ function withoutTrailingSlash(path: string): string {
122
+ return path.endsWith('/') ? path.slice(0, -1) : path
123
+ }
124
+
125
+ /**
126
+ * Whether a claim names a pattern newly added to `.gitignore`.
127
+ *
128
+ * Compared with the trailing slash both sides may or may not carry stripped,
129
+ * since a bullet spells a folder pattern the way `.gitignore` itself does
130
+ * (`web/screenshots/`) and the diff line carries the identical text.
131
+ */
132
+ function coversIgnoreAddition(
133
+ claim: PathClaim,
134
+ ignoreAdditions: readonly string[],
135
+ ): boolean {
136
+ const named = withoutTrailingSlash(claim.path)
137
+ return ignoreAdditions.some(
138
+ (pattern) => withoutTrailingSlash(pattern) === named,
139
+ )
140
+ }
141
+
97
142
  /**
98
143
  * Whether one changed path is the file, or a file under the folder, a claim
99
144
  * names.
@@ -147,6 +192,10 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
147
192
  return { kind: 'refused', reason: 'no-section' }
148
193
  if (read.claims.length === 0) return { kind: 'refused', reason: 'no-claims' }
149
194
 
195
+ const renames = input.renames ?? []
196
+ const ignoreAdditions = input.ignoreAdditions ?? []
197
+ const evidenceUnread = input.evidenceUnread ?? false
198
+
150
199
  const unmet: PathClaim[] = []
151
200
  const unresolved: PathClaim[] = []
152
201
  const named = new Set<string>()
@@ -155,7 +204,15 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
155
204
  const hits = input.changed.filter((path) => covers(claim, path))
156
205
  for (const path of hits) named.add(path)
157
206
  if (hits.length > 0) continue
158
- if (claim.anchored && claim.leading) unmet.push(claim)
207
+
208
+ if (renames.some((rename) => covers(claim, rename.from))) continue
209
+
210
+ if (coversIgnoreAddition(claim, ignoreAdditions)) {
211
+ named.add('.gitignore')
212
+ continue
213
+ }
214
+
215
+ if (claim.anchored && claim.leading && !evidenceUnread) unmet.push(claim)
159
216
  else unresolved.push(claim)
160
217
  }
161
218
 
@@ -170,6 +227,7 @@ export function compareKeyChanges(input: BijectionInput): Bijection {
170
227
  unresolved,
171
228
  unnamed: reached.filter((path) => !owesNoBullet(path)),
172
229
  incidental: reached.filter(owesNoBullet),
230
+ evidenceUnread,
173
231
  }
174
232
  }
175
233