@erclx/canon 4.16.0 → 4.17.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.16.0",
4
+ "version": "4.17.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.16.0",
4
+ "version": "4.17.0",
5
5
  "description": "Infrastructure and quality tooling for developer workflows",
6
6
  "license": "MIT",
7
7
  "bin": {
@@ -164,6 +164,94 @@ export const unreferencedRules: Measure = async (ctx) => {
164
164
  }
165
165
  }
166
166
 
167
+ /**
168
+ * The files the sweep would rewrite, each with its own count, taken from the
169
+ * `paths` array the record carries beside the total.
170
+ *
171
+ * Every field is tested rather than trusted. The record reaches here as parsed
172
+ * JSON rather than as a type the compiler checked, so a shape that moved
173
+ * upstream drops the entries it can no longer read and leaves the count that
174
+ * was read from a field of its own standing.
175
+ */
176
+ function citedPaths(record: { paths?: unknown } | undefined): string[] {
177
+ if (!Array.isArray(record?.paths)) return []
178
+
179
+ return record.paths.flatMap((entry) => {
180
+ const cited = entry as { path?: unknown; rewritten?: unknown }
181
+ if (typeof cited.path !== 'string') return []
182
+ return typeof cited.rewritten === 'number'
183
+ ? [`${cited.path} (${cited.rewritten})`]
184
+ : [cited.path]
185
+ })
186
+ }
187
+
188
+ /**
189
+ * A second run of the records move should rewrite nothing, and the count is
190
+ * only knowable once the folders themselves have landed.
191
+ *
192
+ * `moves` empty means every record folder already sits at `.canon/`, so any
193
+ * citation the sweep would still rewrite is one the move left stale, which is
194
+ * the defect this stage exists to catch. Where `moves` is nonempty the tree has
195
+ * not migrated at all and a nonzero rewrite count is the verb describing its
196
+ * own first pass, so the reading is reported and never failed on.
197
+ *
198
+ * Exit `0` is a tree with nothing to do and exit `2` is a plan drawn without
199
+ * `--write`, so both read a tree and both carry a record. Every other exit is a
200
+ * refusal that planned nothing, which is unmeasured for the reason the markdown
201
+ * stage treats its own refusal exit so.
202
+ */
203
+ export const recordIdempotence: Measure = async (ctx) => {
204
+ const run = await ctx.cli(['migrate', 'records', '--json'])
205
+
206
+ if (run.exitCode !== 0 && run.exitCode !== 2) {
207
+ return {
208
+ emissions: [],
209
+ unmeasured: `The records sweep refused (exit ${run.exitCode}) and planned nothing.`,
210
+ }
211
+ }
212
+
213
+ const record = parseJson(run.stdout) as
214
+ | { moves?: unknown; rewritten?: unknown; paths?: unknown }
215
+ | undefined
216
+ const moves = record?.moves
217
+ const rewritten = record?.rewritten
218
+
219
+ if (!Array.isArray(moves) || typeof rewritten !== 'number') {
220
+ return {
221
+ emissions: [],
222
+ unmeasured:
223
+ 'The records sweep carried no plan, so the stage read no count. Run bun src/cli.ts migrate records --json.',
224
+ }
225
+ }
226
+
227
+ if (moves.length > 0) {
228
+ return {
229
+ emissions: [
230
+ info(
231
+ `${moves.length} record folder(s) still at the old root, with ${rewritten} citation(s) that move with them`,
232
+ ),
233
+ ],
234
+ }
235
+ }
236
+
237
+ if (rewritten > 0) {
238
+ return {
239
+ // The payload already names every file, so listing them here is what
240
+ // separates a count a reader has to go and reproduce from a remedy they
241
+ // can act on. A malformed `paths` costs the list and not the finding,
242
+ // since the count above it was read from a field of its own.
243
+ emissions: citedPaths(record).map((path) => warn(path)),
244
+ failure: `The records are at .canon/ and ${rewritten} citation(s) still name the old root, so a second run of canon migrate records would rewrite them. Repoint each one, or mark it canon-keep-record-root where the sentence has to keep the old spelling.`,
245
+ }
246
+ }
247
+
248
+ return {
249
+ emissions: [
250
+ info('Records at .canon/, and a re-run of the move rewrites nothing'),
251
+ ],
252
+ }
253
+ }
254
+
167
255
  /**
168
256
  * A banned character, word, or spelling is a fact rather than a threshold, so
169
257
  * it fails the push while bullet, paragraph, and depth weight stay advisory.
@@ -4,6 +4,7 @@ import {
4
4
  markdownBans,
5
5
  type Measure,
6
6
  pluginManifests,
7
+ recordIdempotence,
7
8
  sandboxCoverage,
8
9
  seedStandards,
9
10
  standardCriteria,
@@ -337,6 +338,17 @@ export const STAGES: readonly Stage[] = [
337
338
  ],
338
339
  success: 'Rule citations resolve',
339
340
  },
341
+ {
342
+ // A citation naming the old record root resolves to nothing once the
343
+ // folders have moved, and the sweep that would repoint it only runs when a
344
+ // person calls it. Reading its plan back is what turns a silent stale
345
+ // citation into a stopped push, and it sits with the two citation stages
346
+ // above rather than at the end of the file because it answers their
347
+ // question about a root rather than a path.
348
+ id: 'record-idempotence',
349
+ label: 'Record idempotence',
350
+ checks: [{ kind: 'measure', measure: recordIdempotence }],
351
+ },
340
352
  {
341
353
  id: 'markdown-bans',
342
354
  label: 'Markdown bans',