@erclx/canon 4.37.1 → 4.38.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "canon",
3
3
  "description": "Automated governance, versioning, and discovery tools for Claude Code.",
4
- "version": "4.37.1",
4
+ "version": "4.38.0",
5
5
  "author": {
6
6
  "name": "Eric Le",
7
7
  "url": "https://github.com/erclx"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@erclx/canon",
3
3
  "type": "module",
4
- "version": "4.37.1",
4
+ "version": "4.38.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -263,6 +263,15 @@ function reportRecords(plan: RecordsPlan, records: number): void {
263
263
  if (plan.excluded.length > 0) {
264
264
  logInfo(`${plural(plan.excluded.length, 'file')} excluded from the sweep.`)
265
265
  }
266
+
267
+ // A list rather than a count. A coupled file is the handful an operator has
268
+ // to open by hand, and a bare count gives them nothing to act on.
269
+ if (plan.coupled.length > 0) {
270
+ logInfo(
271
+ `${plural(plan.coupled.length, 'file')} couple to an excluded path:`,
272
+ )
273
+ for (const path of plan.coupled) logInfo(` ${path}`)
274
+ }
266
275
  }
267
276
 
268
277
  function toRecordsRecord(
@@ -279,6 +288,7 @@ function toRecordsRecord(
279
288
  rewritten: plan.rewritten,
280
289
  kept: plan.kept,
281
290
  excluded: plan.excluded.length,
291
+ coupled: plan.coupled,
282
292
  records,
283
293
  paths: plan.entries.map((entry) => ({
284
294
  path: entry.path,
@@ -91,6 +91,13 @@ function escape(value: string): string {
91
91
  * pair into two copies of the new one and shellcheck reports a pattern that can
92
92
  * never match. The guard then stops firing in a project the move has not
93
93
  * reached, which is silent: the index goes stale while every save succeeds.
94
+ *
95
+ * A file outside this list can still couple to one inside it, naming an
96
+ * excluded path as plain text while it also carries a live citation of its
97
+ * own, which `referencesExcluded` reports separately below. Widening this list
98
+ * or `EXCLUDED_SUFFIXES` to catch that case would mean guessing at a naming
99
+ * convention no project here declares, where the coupling check instead reads
100
+ * what the file's own text already says.
94
101
  */
95
102
  const EXCLUDED_PREFIXES: readonly string[] = [
96
103
  'src/migrate/',
@@ -109,6 +116,25 @@ export function isExcludedPath(path: string): boolean {
109
116
  return EXCLUDED_SUFFIXES.some((suffix) => path.endsWith(suffix))
110
117
  }
111
118
 
119
+ /**
120
+ * Whether `text` names an `EXCLUDED_PREFIXES` or `EXCLUDED_PATHS` entry as
121
+ * literal substring text, which is how a rewritten file can couple to one this
122
+ * module leaves alone: a citation gets rewritten clean while a line a few away
123
+ * still spells the excluded path it was testing against.
124
+ *
125
+ * `EXCLUDED_SUFFIXES` plays no part here, since a suffix names a file's own
126
+ * shape rather than text its content could quote. The one gap this cannot
127
+ * close is a file that names the excluded surface by description rather than
128
+ * by path, such as "the exemption hook" rather than `.claude/hooks/`, which is
129
+ * the same limit `isExcludedPath` already carries for content it cannot parse.
130
+ */
131
+ export function referencesExcluded(text: string): boolean {
132
+ return (
133
+ EXCLUDED_PATHS.some((path) => text.includes(path)) ||
134
+ EXCLUDED_PREFIXES.some((prefix) => text.includes(prefix))
135
+ )
136
+ }
137
+
112
138
  /**
113
139
  * The roots holding nothing but records, so a path under one is a record
114
140
  * whatever it is named.
@@ -299,6 +325,7 @@ export interface RecordsPlan {
299
325
  readonly collisions: readonly string[]
300
326
  readonly entries: readonly CitationEntry[]
301
327
  readonly excluded: readonly string[]
328
+ readonly coupled: readonly string[]
302
329
  readonly rewritten: number
303
330
  readonly kept: number
304
331
  }
@@ -318,6 +345,7 @@ export function planRecordsMove(
318
345
  const moves = planFolderMoves(root)
319
346
  const entries: CitationEntry[] = []
320
347
  const excluded: string[] = []
348
+ const coupled: string[] = []
321
349
  let kept = 0
322
350
 
323
351
  for (const source of sources) {
@@ -339,6 +367,8 @@ export function planRecordsMove(
339
367
  kept += counts.kept
340
368
  if (counts.rewritten === 0) continue
341
369
 
370
+ if (referencesExcluded(source.text)) coupled.push(source.path)
371
+
342
372
  entries.push({
343
373
  path: source.path,
344
374
  text: rewriteText(source.text),
@@ -352,6 +382,7 @@ export function planRecordsMove(
352
382
  collisions: collisions(root, moves),
353
383
  entries,
354
384
  excluded,
385
+ coupled,
355
386
  rewritten: entries.reduce((sum, entry) => sum + entry.rewritten, 0),
356
387
  kept,
357
388
  }