@erclx/canon 4.116.0 → 4.118.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/canon-cli/SKILL.md +12 -12
- package/claude/skills/canon-operator/SKILL.md +1 -1
- package/claude/skills/create-skill/REQUIREMENT.md +3 -0
- package/claude/skills/create-skill/SKILL.md +3 -1
- package/claude/skills/docs-fold/SKILL.md +1 -1
- package/claude/skills/draft-ready/references/assembly.md +8 -0
- package/claude/skills/memory-review/SKILL.md +4 -4
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -0
- package/claude/skills/plan-feature/SKILL.md +7 -0
- package/claude/skills/review-pr/REQUIREMENT.md +3 -0
- package/claude/skills/review-pr/SKILL.md +5 -3
- package/claude/skills/role-orchestrator/REQUIREMENT.md +2 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +14 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +2 -2
- package/claude/skills/role-worker/REQUIREMENT.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -2
- package/claude/skills/session-compact/REQUIREMENT.md +2 -1
- package/claude/skills/session-compact/SKILL.md +5 -1
- package/claude/skills/target-check/references/domains.md +1 -1
- package/claude/skills/write-human/references/source-material.md +2 -2
- package/docs/agents/commands.md +1 -1
- package/docs/agents/context-audit-checks.md +3 -29
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/install-and-sync.md +24 -18
- package/docs/agents/markdown-audit.md +7 -3
- package/docs/agents/skills-audit.md +8 -1
- package/docs/target-projects.md +2 -2
- package/docs/workflow/ai-workflow.md +9 -9
- package/docs/workflow/operating-model.md +1 -1
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/510-context.md +0 -12
- package/governance/rules/core/005-behavior.md +1 -2
- package/governance/rules/core/015-output.md +1 -3
- package/governance/rules/core/035-tasks.md +1 -3
- package/governance/rules/core/045-memory.md +0 -1
- package/governance/rules/core/055-scratch.md +2 -4
- package/governance/rules/core/070-planning.md +1 -4
- package/governance/rules/core/087-git.md +2 -2
- package/governance/rules/core/090-code-comments.md +1 -1
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/rules/ui/460-design-taste.md +0 -15
- package/package.json +1 -1
- package/scripts/core/check-capability-seeding.sh +4 -2
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/skills-audit.ts +59 -2
- package/src/commands/claude.ts +30 -0
- package/src/commands/context.ts +11 -111
- package/src/commands/markdown.ts +63 -2
- package/src/commands/sync.ts +2 -2
- package/src/context/audit.ts +9 -153
- package/src/gate/measures.ts +97 -0
- package/src/gate/stages.ts +21 -2
- package/src/gov/adapter.ts +8 -6
- package/src/markdown/ceiling.ts +70 -0
- package/src/markdown/structure.ts +22 -0
- package/src/records/validate.ts +5 -1
- package/src/sync/check.ts +7 -1
- package/src/sync/engine.ts +90 -10
- package/src/sync/stamp.ts +44 -0
- package/src/tooling/inject.ts +13 -5
- package/src/tooling/manifest.ts +9 -0
- package/src/tooling/scan.ts +23 -6
- package/src/tooling/subfolder.ts +3 -3
- package/standards/context.md +2 -2
- package/standards/markdown.md +2 -0
- package/standards/plan.md +24 -1
- package/standards/skill.md +1 -0
- package/tooling/base/reference.md +2 -2
- package/tooling/web/configs/e2e/screenshot.ts +43 -7
- package/tooling/web/reference.md +1 -1
- package/src/context/narration.ts +0 -99
- /package/tooling/base/{configs → seeds}/.github/pull_request_template.md +0 -0
- /package/tooling/base/{configs → seeds}/.github/workflows/verify.yml +0 -0
package/src/gate/measures.ts
CHANGED
|
@@ -12,7 +12,11 @@ import {
|
|
|
12
12
|
isOverLength,
|
|
13
13
|
measureArchitecture,
|
|
14
14
|
} from '@/context/architecture'
|
|
15
|
+
import { auditSkills } from '@/claude/skills-audit'
|
|
15
16
|
import { listRepositoryFiles } from '@/git-files'
|
|
17
|
+
import { ceilingFindings } from '@/markdown/ceiling'
|
|
18
|
+
import { resolveMarkdown } from '@/markdown/files'
|
|
19
|
+
import { CHECKPOINTS } from '@/markdown/structure'
|
|
16
20
|
import {
|
|
17
21
|
isShippedCorpus,
|
|
18
22
|
REFERENCE_MARKER,
|
|
@@ -21,6 +25,7 @@ import {
|
|
|
21
25
|
type ShippedReference,
|
|
22
26
|
} from '@/shipped/references'
|
|
23
27
|
import { surfaceDir } from '@/surface-root'
|
|
28
|
+
import { plural } from '@/ui'
|
|
24
29
|
import {
|
|
25
30
|
README_PARAPHRASE_MARKER,
|
|
26
31
|
readmeCitationsIn,
|
|
@@ -273,6 +278,98 @@ export const architectureRecord: Measure = async (ctx) => {
|
|
|
273
278
|
}
|
|
274
279
|
}
|
|
275
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Every skill body and reference against the no-dated-provenance rule, across
|
|
283
|
+
* both corpora `auditSkills` reads.
|
|
284
|
+
*
|
|
285
|
+
* Read in-process rather than through `claude skills audit`, whose exit code
|
|
286
|
+
* stays a missing requirement only, so a target running the verb is told about
|
|
287
|
+
* a date without failing on one. This repository holds the corpus at zero,
|
|
288
|
+
* which is what lets the same finding fail here.
|
|
289
|
+
*/
|
|
290
|
+
export const skillProvenance: Measure = async (ctx) => {
|
|
291
|
+
const report = await auditSkills(ctx.root)
|
|
292
|
+
if (report.corpora.length === 0) {
|
|
293
|
+
return {
|
|
294
|
+
emissions: [],
|
|
295
|
+
unmeasured: 'no skill corpus resolved, so no body was read.',
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const found = report.datedProvenance
|
|
300
|
+
if (found.length > 0) {
|
|
301
|
+
const where = found
|
|
302
|
+
.map((finding) => `${finding.rel} ${finding.detail}`)
|
|
303
|
+
.join(', ')
|
|
304
|
+
return {
|
|
305
|
+
emissions: [],
|
|
306
|
+
failure: `${plural(found.length, 'date')} in skill prose: ${where}. Move the incident to REQUIREMENT.md under Gap or leave it to git, or fence the date or put it in a code span when it is an example.`,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
emissions: [
|
|
312
|
+
info(`No dated provenance across ${plural(report.skills, 'skill')}`),
|
|
313
|
+
],
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Every tracked markdown document against the whole-document ceiling.
|
|
319
|
+
*
|
|
320
|
+
* Report-only for now: a document past the ceiling warns and the stage still
|
|
321
|
+
* passes, since the corpus carried dozens past it when the stage landed. The
|
|
322
|
+
* flip to a failure is a separate change once the count reads zero, and it
|
|
323
|
+
* replaces the warn path rather than sitting beside it.
|
|
324
|
+
*
|
|
325
|
+
* Read in-process rather than through `canon markdown audit`, whose exit code
|
|
326
|
+
* stays bans and dead links only. The plan and groundwork skills run that audit
|
|
327
|
+
* on a gitignored record, and a gating length there would fail every long plan.
|
|
328
|
+
*/
|
|
329
|
+
export const documentCeiling: Measure = async (ctx) => {
|
|
330
|
+
const scope = await resolveMarkdown(ctx.root, [])
|
|
331
|
+
if (scope.kind === 'unavailable') {
|
|
332
|
+
return {
|
|
333
|
+
emissions: [],
|
|
334
|
+
unmeasured: 'git could not list the tree, so no document was measured.',
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const documents = scope.files.flatMap((rel) => {
|
|
339
|
+
try {
|
|
340
|
+
return [{ rel, source: readFileSync(join(ctx.root, rel), 'utf8') }]
|
|
341
|
+
} catch {
|
|
342
|
+
return []
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
const findings = ceilingFindings(documents)
|
|
346
|
+
const over = findings.filter((finding) => finding.exempt === null)
|
|
347
|
+
const exempt = findings.length - over.length
|
|
348
|
+
const ceiling = CHECKPOINTS.ceiling
|
|
349
|
+
|
|
350
|
+
if (over.length === 0) {
|
|
351
|
+
const exemptNote = exempt > 0 ? `, ${exempt} exempt` : ''
|
|
352
|
+
return {
|
|
353
|
+
emissions: [
|
|
354
|
+
info(
|
|
355
|
+
`No document past the ${ceiling}-line ceiling across ${plural(documents.length, 'markdown file')}${exemptNote}`,
|
|
356
|
+
),
|
|
357
|
+
],
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
emissions: [
|
|
363
|
+
...over.map((finding) =>
|
|
364
|
+
warn(`${finding.rel} ${finding.renderedLines} rendered lines`),
|
|
365
|
+
),
|
|
366
|
+
info(
|
|
367
|
+
`${plural(over.length, 'document')} past the ${ceiling}-line ceiling, ${exempt} exempt. Reported only, so the push is not held.`,
|
|
368
|
+
),
|
|
369
|
+
],
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
276
373
|
/**
|
|
277
374
|
* A second run of the records move should rewrite nothing, and the count is
|
|
278
375
|
* only knowable once the folders themselves have landed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
auditSet,
|
|
4
4
|
captureStamps,
|
|
5
5
|
clientCommandCitations,
|
|
6
|
+
documentCeiling,
|
|
6
7
|
markdownBans,
|
|
7
8
|
type Measure,
|
|
8
9
|
pluginManifests,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
seedStandards,
|
|
13
14
|
rawFieldFileReference,
|
|
14
15
|
shippedReferences,
|
|
16
|
+
skillProvenance,
|
|
15
17
|
standardCriteria,
|
|
16
18
|
unreferencedRules,
|
|
17
19
|
visualPathGlobs,
|
|
@@ -333,9 +335,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
333
335
|
checks: [{ kind: 'measure', measure: unreferencedRules }],
|
|
334
336
|
},
|
|
335
337
|
{
|
|
336
|
-
// Only the citation half of the audit gates.
|
|
338
|
+
// Only the citation half of the audit gates. Its length, depth, table, and
|
|
337
339
|
// index findings are judgment thresholds, and failing a push on one would
|
|
338
|
-
// make the stage something to route around.
|
|
340
|
+
// make the stage something to route around. The one length reading that
|
|
341
|
+
// will gate is the whole-document ceiling, which the Document ceiling stage
|
|
342
|
+
// below owns rather than this audit.
|
|
339
343
|
id: 'context-citations',
|
|
340
344
|
label: 'Context citations',
|
|
341
345
|
checks: [
|
|
@@ -355,6 +359,13 @@ export const STAGES: readonly Stage[] = [
|
|
|
355
359
|
label: 'Architecture record',
|
|
356
360
|
checks: [{ kind: 'measure', measure: architectureRecord }],
|
|
357
361
|
},
|
|
362
|
+
{
|
|
363
|
+
// Unscoped, since any branch can grow any document. It warns and passes
|
|
364
|
+
// until the corpus sits under the ceiling, then flips to a failure.
|
|
365
|
+
id: 'document-ceiling',
|
|
366
|
+
label: 'Document ceiling',
|
|
367
|
+
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
368
|
+
},
|
|
358
369
|
{
|
|
359
370
|
// A rule citing a file that moved fails silently. The consumed-copy drift
|
|
360
371
|
// stage passes an authored rule and its copy that are wrong together, and
|
|
@@ -458,6 +469,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
458
469
|
],
|
|
459
470
|
success: 'Skill requirements present',
|
|
460
471
|
},
|
|
472
|
+
{
|
|
473
|
+
// The audit verb only reports a dated line, so a target is told without
|
|
474
|
+
// failing. This stage makes the same finding fail here, where the corpus is
|
|
475
|
+
// held at zero. Unscoped, since any branch can add a line to any body.
|
|
476
|
+
id: 'skill-provenance',
|
|
477
|
+
label: 'Skill provenance',
|
|
478
|
+
checks: [{ kind: 'measure', measure: skillProvenance }],
|
|
479
|
+
},
|
|
461
480
|
{
|
|
462
481
|
id: 'standard-criteria',
|
|
463
482
|
label: 'Standard success criteria',
|
package/src/gov/adapter.ts
CHANGED
|
@@ -44,12 +44,13 @@ export function indexSourceRules(root: string): Map<string, string> {
|
|
|
44
44
|
* path, so a rule that moved between subdirectories in the toolkit still
|
|
45
45
|
* syncs into the subdirectory the target already uses.
|
|
46
46
|
*
|
|
47
|
-
* Declares no `projectSubdir`.
|
|
48
|
-
* `.claude/rules/project
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
47
|
+
* Declares `ownsInstalledRoot` and no `projectSubdir`. A project keeps its own
|
|
48
|
+
* rules under `.claude/rules/project/`, which `installedRoot` narrowing to
|
|
49
|
+
* `.claude/rules/canon/` leaves outside the walk, so everything the walk does
|
|
50
|
+
* reach is the toolkit's. A rule there with no source is one the toolkit
|
|
51
|
+
* retired or renamed, and the sync deletes it rather than leaving it loaded.
|
|
52
|
+
* A `projectSubdir` would compute a stale `canon/project/` destination, which
|
|
53
|
+
* is wrong rather than merely redundant.
|
|
53
54
|
*/
|
|
54
55
|
export function createGovAdapter(root: string): SyncAdapter {
|
|
55
56
|
const index = indexSourceRules(root)
|
|
@@ -65,6 +66,7 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
65
66
|
index.get(basename(file.path, '.md')),
|
|
66
67
|
collectRetired: (target: string) => collectRetiredGov(target),
|
|
67
68
|
collectMissing: (target: string) => collectMissingGov(root, target),
|
|
69
|
+
ownsInstalledRoot: true,
|
|
68
70
|
stamp: { domain: 'governance', toolkitRoot: root },
|
|
69
71
|
}
|
|
70
72
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { bodyLines } from '@/markdown/scan'
|
|
3
|
+
import { CHECKPOINTS, documentHeight } from '@/markdown/structure'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The marker exempting one document from the ceiling, written as a whole line
|
|
7
|
+
* outside a fence and naming a reason.
|
|
8
|
+
*
|
|
9
|
+
* The whole line rather than anywhere on it, so a standard quoting the marker
|
|
10
|
+
* inside a code span to document it does not exempt itself. A marker with no
|
|
11
|
+
* reason does not count, for the reason `isMarked` in `src/exempt-marker.ts`
|
|
12
|
+
* gives: a bare token reads as a line that meant to say something and did not.
|
|
13
|
+
*/
|
|
14
|
+
const EXEMPT_MARKER = /^<!--\s*canon-length-exempt:[ \t]*(\S.*?)\s*-->$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A changelog is exempt by name because the release tool rewrites it and would
|
|
18
|
+
* not keep a marker in place. A committed list of exempt paths was the
|
|
19
|
+
* alternative, and it turns every rename into a second edit with nothing to
|
|
20
|
+
* check the list against the tree.
|
|
21
|
+
*/
|
|
22
|
+
const CHANGELOG = 'CHANGELOG.md'
|
|
23
|
+
const CHANGELOG_REASON = 'CHANGELOG.md is written by the release tool'
|
|
24
|
+
|
|
25
|
+
export interface CeilingDocument {
|
|
26
|
+
readonly rel: string
|
|
27
|
+
readonly source: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CeilingFinding {
|
|
31
|
+
readonly rel: string
|
|
32
|
+
readonly renderedLines: number
|
|
33
|
+
/** The stated reason, or null where the document is not exempt. */
|
|
34
|
+
readonly exempt: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The reason a document is exempt from the ceiling, or null where it is not. */
|
|
38
|
+
export function lengthExemption(rel: string, source: string): string | null {
|
|
39
|
+
if (basename(rel) === CHANGELOG) return CHANGELOG_REASON
|
|
40
|
+
|
|
41
|
+
for (const line of bodyLines(source)) {
|
|
42
|
+
if (line.fenced) continue
|
|
43
|
+
const match = EXEMPT_MARKER.exec(line.text.trim())
|
|
44
|
+
if (match) return match[1]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Every document past the ceiling, longest first, exempt ones included with
|
|
52
|
+
* their reason so a report can count them rather than hide them.
|
|
53
|
+
*/
|
|
54
|
+
export function ceilingFindings(
|
|
55
|
+
documents: readonly CeilingDocument[],
|
|
56
|
+
): CeilingFinding[] {
|
|
57
|
+
return documents
|
|
58
|
+
.map(({ rel, source }) => ({
|
|
59
|
+
rel,
|
|
60
|
+
renderedLines: documentHeight(source),
|
|
61
|
+
source,
|
|
62
|
+
}))
|
|
63
|
+
.filter(({ renderedLines }) => renderedLines > CHECKPOINTS.ceiling)
|
|
64
|
+
.map(({ rel, renderedLines, source }) => ({
|
|
65
|
+
rel,
|
|
66
|
+
renderedLines,
|
|
67
|
+
exempt: lengthExemption(rel, source),
|
|
68
|
+
}))
|
|
69
|
+
.sort((a, b) => b.renderedLines - a.renderedLines)
|
|
70
|
+
}
|
|
@@ -116,6 +116,7 @@ export const CHECKPOINTS = {
|
|
|
116
116
|
cadence: 3,
|
|
117
117
|
spread: 5,
|
|
118
118
|
opener: 2,
|
|
119
|
+
ceiling: 300,
|
|
119
120
|
} as const
|
|
120
121
|
|
|
121
122
|
/**
|
|
@@ -167,6 +168,11 @@ export interface Checkpoints {
|
|
|
167
168
|
readonly spread: number
|
|
168
169
|
/** Times one opening word may open a sentence in a paragraph before it is a pattern. */
|
|
169
170
|
readonly opener: number
|
|
171
|
+
/**
|
|
172
|
+
* Rendered lines a whole document may reach, read by the Document ceiling
|
|
173
|
+
* gate stage rather than by any exit code of the audit.
|
|
174
|
+
*/
|
|
175
|
+
readonly ceiling: number
|
|
170
176
|
}
|
|
171
177
|
|
|
172
178
|
export interface BulletFinding {
|
|
@@ -247,6 +253,22 @@ export function renderedHeight(text: string, width = RENDER_WIDTH): number {
|
|
|
247
253
|
return Math.max(1, Math.ceil(visibleText(text).length / width))
|
|
248
254
|
}
|
|
249
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Height a whole source occupies once wrapped, frontmatter and fenced blocks
|
|
258
|
+
* included.
|
|
259
|
+
*
|
|
260
|
+
* Nothing is excluded because a session pays for every line it loads, which is
|
|
261
|
+
* what separates this from the depth measure. The context audit's length
|
|
262
|
+
* reading and the document ceiling both count through here, so the two report
|
|
263
|
+
* one unit.
|
|
264
|
+
*/
|
|
265
|
+
export function documentHeight(source: string, width = RENDER_WIDTH): number {
|
|
266
|
+
return source
|
|
267
|
+
.replace(/\n$/, '')
|
|
268
|
+
.split('\n')
|
|
269
|
+
.reduce((sum, text) => sum + renderedHeight(text, width), 0)
|
|
270
|
+
}
|
|
271
|
+
|
|
250
272
|
/**
|
|
251
273
|
* Reports whether a run is the peer list the standard exempts.
|
|
252
274
|
*
|
package/src/records/validate.ts
CHANGED
|
@@ -271,7 +271,9 @@ const PLAN_SECTIONS = [
|
|
|
271
271
|
'Summary',
|
|
272
272
|
'Constraints',
|
|
273
273
|
'Files to touch',
|
|
274
|
+
'Verification',
|
|
274
275
|
'Risks',
|
|
276
|
+
'Review focus',
|
|
275
277
|
'Questions',
|
|
276
278
|
] as const
|
|
277
279
|
|
|
@@ -280,7 +282,9 @@ type PlanSection = (typeof PLAN_SECTIONS)[number]
|
|
|
280
282
|
const PLAN_REQUIRED: readonly PlanSection[] = [
|
|
281
283
|
'Summary',
|
|
282
284
|
'Files to touch',
|
|
285
|
+
'Verification',
|
|
283
286
|
'Risks',
|
|
287
|
+
'Review focus',
|
|
284
288
|
'Questions',
|
|
285
289
|
]
|
|
286
290
|
|
|
@@ -293,7 +297,7 @@ const MARKER_LINE = /^(?:##[ \t]+(.+?)|\*\*(.+?):\*\*)[ \t]*$/
|
|
|
293
297
|
|
|
294
298
|
/**
|
|
295
299
|
* A section opens as a bold label or as an H2 and both count. The corpus writes
|
|
296
|
-
* `Summary` as a heading and the
|
|
300
|
+
* `Summary` as a heading and the others as bold labels, and roughly a fifth
|
|
297
301
|
* of it swaps one for the other. Reporting the variant would fail nearly every
|
|
298
302
|
* plan present on the rule a reader is least served by, which is what teaches
|
|
299
303
|
* them to skip the output.
|
package/src/sync/check.ts
CHANGED
|
@@ -79,6 +79,7 @@ export interface StateCounts {
|
|
|
79
79
|
readonly orphaned: number
|
|
80
80
|
readonly stranded: number
|
|
81
81
|
readonly missing: number
|
|
82
|
+
readonly retired: number
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
export interface DomainReport {
|
|
@@ -314,6 +315,7 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
314
315
|
orphaned: count(entries, 'orphaned'),
|
|
315
316
|
stranded: count(entries, 'stranded'),
|
|
316
317
|
missing: count(entries, 'missing'),
|
|
318
|
+
retired: count(entries, 'retired'),
|
|
317
319
|
}
|
|
318
320
|
}
|
|
319
321
|
|
|
@@ -342,6 +344,9 @@ export function countStates(entries: readonly ScanEntry[]): StateCounts {
|
|
|
342
344
|
* sync that adds a rule silently changes what a project is governed by, and
|
|
343
345
|
* nobody chose that, so gating CI on the count would pressure a target into
|
|
344
346
|
* adopting a rule nobody picked.
|
|
347
|
+
*
|
|
348
|
+
* `retired` counts, since a sync deletes it mechanically. A target's CI goes
|
|
349
|
+
* red the day a release retires a rule it holds, and one sync clears it.
|
|
345
350
|
*/
|
|
346
351
|
export function hasDrift(report: CheckReport): boolean {
|
|
347
352
|
if (report.unmigrated.length > 0) return true
|
|
@@ -351,7 +356,8 @@ export function hasDrift(report: CheckReport): boolean {
|
|
|
351
356
|
domain.counts.stale +
|
|
352
357
|
domain.counts.customized +
|
|
353
358
|
domain.counts.drifted +
|
|
354
|
-
domain.counts.stranded
|
|
359
|
+
domain.counts.stranded +
|
|
360
|
+
domain.counts.retired >
|
|
355
361
|
0,
|
|
356
362
|
)
|
|
357
363
|
}
|
package/src/sync/engine.ts
CHANGED
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
type DomainHashes,
|
|
9
9
|
hashFile,
|
|
10
10
|
readStamp,
|
|
11
|
+
type Stamp,
|
|
12
|
+
type StampDomain,
|
|
11
13
|
type StampSource,
|
|
12
14
|
stampedHashes,
|
|
15
|
+
stampedVersion,
|
|
13
16
|
toStampKey,
|
|
14
17
|
writeStamp,
|
|
15
18
|
} from '@/sync/stamp'
|
|
@@ -26,6 +29,8 @@ import {
|
|
|
26
29
|
palette,
|
|
27
30
|
select,
|
|
28
31
|
} from '@/ui'
|
|
32
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
33
|
+
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* One installed file in three path flavours: absolute, relative to the
|
|
@@ -58,10 +63,12 @@ export type SyncChange =
|
|
|
58
63
|
* who moved it. `stale` and `customized` need the stamp to tell apart, so
|
|
59
64
|
* `drifted` stays the verdict for a difference no stamp covers.
|
|
60
65
|
*
|
|
61
|
-
* `orphaned` and `stranded`
|
|
62
|
-
* separate because they need
|
|
63
|
-
* orphaned and stays that way forever. A
|
|
64
|
-
*
|
|
66
|
+
* `orphaned`, `retired`, and `stranded` all mean the walk found no source, and
|
|
67
|
+
* they are separate because they need different treatment. A project-authored
|
|
68
|
+
* file is orphaned and stays that way forever. A file under a root the toolkit
|
|
69
|
+
* owns outright is retired, since only the toolkit ever wrote there, and a
|
|
70
|
+
* sync deletes it. A stamped file the toolkit no longer installs to is
|
|
71
|
+
* stranded, which is a relocation waiting on a decision.
|
|
65
72
|
*
|
|
66
73
|
* `missing` is the one state the walk cannot produce on its own, since the
|
|
67
74
|
* walk only iterates files that exist. It comes from `collectMissing`
|
|
@@ -73,6 +80,7 @@ export type EntryState =
|
|
|
73
80
|
| 'customized'
|
|
74
81
|
| 'drifted'
|
|
75
82
|
| 'orphaned'
|
|
83
|
+
| 'retired'
|
|
76
84
|
| 'stranded'
|
|
77
85
|
| 'missing'
|
|
78
86
|
|
|
@@ -152,6 +160,14 @@ export interface SyncAdapter {
|
|
|
152
160
|
* inference orphans a file sitting anywhere else.
|
|
153
161
|
*/
|
|
154
162
|
readonly projectSubdir?: string
|
|
163
|
+
/**
|
|
164
|
+
* Every file under `installedRoot` is the toolkit's, so a walked file with
|
|
165
|
+
* no source is a leftover of a rule the toolkit retired or renamed rather
|
|
166
|
+
* than something a project wrote. Setting it turns that file into a
|
|
167
|
+
* `retired` entry and queues its delete, edited or not, whatever the stamp
|
|
168
|
+
* holds. Unset, the file stays `orphaned` and untouched.
|
|
169
|
+
*/
|
|
170
|
+
readonly ownsInstalledRoot?: boolean
|
|
155
171
|
/** Defaults to applying. */
|
|
156
172
|
readonly nonInteractive?: NonInteractivePolicy
|
|
157
173
|
/** Runs on a completed sync, including one with no changes. */
|
|
@@ -195,15 +211,18 @@ export function listInstalled(
|
|
|
195
211
|
|
|
196
212
|
/**
|
|
197
213
|
* Classifies every installed file against its source without writing anything.
|
|
198
|
-
* A file with no source is
|
|
199
|
-
*
|
|
214
|
+
* A file with no source is deleted only when the adapter owns its installed
|
|
215
|
+
* root outright, and left alone otherwise, which is what keeps a
|
|
216
|
+
* project-authored file alive across a sync of a root the project shares.
|
|
200
217
|
*/
|
|
201
218
|
export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
202
219
|
const entries: ScanEntry[] = []
|
|
203
220
|
const changes: SyncChange[] = []
|
|
204
221
|
const unattributed: UnattributedFile[] = []
|
|
205
222
|
|
|
206
|
-
const
|
|
223
|
+
const stamp = readStamp(target)
|
|
224
|
+
const hashes = stampedHashes(stamp, adapter.stamp?.domain)
|
|
225
|
+
const newerInstall = newerInstallingVersion(stamp, adapter.stamp?.domain)
|
|
207
226
|
const walked = new Set<string>()
|
|
208
227
|
|
|
209
228
|
for (const file of listInstalled(
|
|
@@ -222,7 +241,18 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
|
|
|
222
241
|
const source = adapter.locateSource(file)
|
|
223
242
|
|
|
224
243
|
if (source === undefined || !existsSync(source)) {
|
|
225
|
-
|
|
244
|
+
if (adapter.ownsInstalledRoot !== true) {
|
|
245
|
+
entries.push(misplacedOrphan(adapter, target, file))
|
|
246
|
+
} else if (newerInstall !== undefined) {
|
|
247
|
+
entries.push(heldForNewerInstall(file, newerInstall))
|
|
248
|
+
} else {
|
|
249
|
+
entries.push({
|
|
250
|
+
state: 'retired',
|
|
251
|
+
rel: file.rel,
|
|
252
|
+
notice: `${file.rel} (no longer shipped by the toolkit, removing)`,
|
|
253
|
+
})
|
|
254
|
+
changes.push({ kind: 'delete', dest: file.path, rel: file.rel })
|
|
255
|
+
}
|
|
226
256
|
continue
|
|
227
257
|
}
|
|
228
258
|
|
|
@@ -438,6 +468,8 @@ function report(adapter: SyncAdapter, plan: SyncPlan): void {
|
|
|
438
468
|
)
|
|
439
469
|
else if (entry.state === 'customized')
|
|
440
470
|
logWarn(`${entry.rel} (locally customized)`)
|
|
471
|
+
else if (entry.state === 'retired')
|
|
472
|
+
logWarn(entry.notice ?? `${entry.rel} (no longer shipped by the toolkit)`)
|
|
441
473
|
else if (entry.state === 'stranded')
|
|
442
474
|
logWarn(`${entry.rel} (installed here by an older toolkit, now moved)`)
|
|
443
475
|
else if (entry.state === 'missing')
|
|
@@ -528,6 +560,10 @@ function isProjectAuthored(adapter: SyncAdapter, file: InstalledFile): boolean {
|
|
|
528
560
|
* Naming the destination is all this does. Moving the file rewrites a path the
|
|
529
561
|
* project's own rules, skills, and docs may cite, so the sync leaves it where
|
|
530
562
|
* it is.
|
|
563
|
+
*
|
|
564
|
+
* An adapter declaring `ownsInstalledRoot` never reaches this, since owning
|
|
565
|
+
* the root answers the question neither the name nor the stamp can, and its
|
|
566
|
+
* sourceless file is retired instead.
|
|
531
567
|
*/
|
|
532
568
|
function misplacedOrphan(
|
|
533
569
|
adapter: SyncAdapter,
|
|
@@ -549,6 +585,49 @@ function misplacedOrphan(
|
|
|
549
585
|
}
|
|
550
586
|
}
|
|
551
587
|
|
|
588
|
+
/**
|
|
589
|
+
* The release that last wrote this domain, when it is newer than the one
|
|
590
|
+
* running. A binary older than that release cannot tell a rule it retired
|
|
591
|
+
* from one added after it, so it holds every sourceless file rather than
|
|
592
|
+
* deleting rules the newer release installed. An unparseable side holds too,
|
|
593
|
+
* since deleting on a comparison that never ran is the failure this guards.
|
|
594
|
+
* A stamp with no version predates the field and is never newer.
|
|
595
|
+
*/
|
|
596
|
+
function newerInstallingVersion(
|
|
597
|
+
stamp: Stamp | undefined,
|
|
598
|
+
domain: StampDomain | undefined,
|
|
599
|
+
): { readonly stamped: string; readonly running: string } | undefined {
|
|
600
|
+
if (domain === undefined) return undefined
|
|
601
|
+
|
|
602
|
+
const stamped = stampedVersion(stamp, domain)
|
|
603
|
+
if (stamped === undefined) return undefined
|
|
604
|
+
|
|
605
|
+
const running = readInstalled().version ?? UNKNOWN_LABEL
|
|
606
|
+
const parsedStamped = parseVersion(stamped)
|
|
607
|
+
const parsedRunning = parseVersion(running)
|
|
608
|
+
|
|
609
|
+
if (
|
|
610
|
+
parsedStamped !== undefined &&
|
|
611
|
+
parsedRunning !== undefined &&
|
|
612
|
+
compareVersions(parsedStamped, parsedRunning) <= 0
|
|
613
|
+
) {
|
|
614
|
+
return undefined
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { stamped, running }
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function heldForNewerInstall(
|
|
621
|
+
file: InstalledFile,
|
|
622
|
+
versions: { readonly stamped: string; readonly running: string },
|
|
623
|
+
): ScanEntry {
|
|
624
|
+
return {
|
|
625
|
+
state: 'orphaned',
|
|
626
|
+
rel: file.rel,
|
|
627
|
+
notice: `${file.rel} (installed by canon ${versions.stamped}, newer than this ${versions.running}. Upgrade canon to sync it.)`,
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
552
631
|
function isInside(target: string, path: string): boolean {
|
|
553
632
|
const rel = relative(target, path)
|
|
554
633
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel)
|
|
@@ -567,8 +646,9 @@ function hasUnattributedDrift(plan: SyncPlan): boolean {
|
|
|
567
646
|
/**
|
|
568
647
|
* Records what the toolkit placed, after the copies land, so a partial apply
|
|
569
648
|
* that throws leaves the previous stamp rather than a claim the target does not
|
|
570
|
-
* meet. A file with no source,
|
|
571
|
-
*
|
|
649
|
+
* meet. A file with no source stays out, whether it is project-authored or a
|
|
650
|
+
* retired leftover a declined sync did not delete, and so does one orphaned by
|
|
651
|
+
* location.
|
|
572
652
|
*
|
|
573
653
|
* Reads the installed tree rather than the caller's file list, so a partial
|
|
574
654
|
* install still stamps the domain's whole installed set.
|
package/src/sync/stamp.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { dirname, join, sep } from 'node:path'
|
|
|
5
5
|
import { execa } from 'execa'
|
|
6
6
|
import { resolveExisting } from '@/legacy-path'
|
|
7
7
|
import { recordTarget } from '@/targets/registry'
|
|
8
|
+
import { compareVersions, parseVersion } from '@/version/compare'
|
|
9
|
+
import { readInstalled } from '@/version/installed'
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Domains the stamp can record. Governance and design both attribute file by
|
|
@@ -38,6 +40,14 @@ export type DomainHashes = Readonly<Record<string, string>>
|
|
|
38
40
|
export interface DomainStamp {
|
|
39
41
|
/** Toolkit revision this domain last synced from. Absent outside a git clone. */
|
|
40
42
|
readonly commit?: string
|
|
43
|
+
/**
|
|
44
|
+
* Newest canon release that has written this domain. A sync deletes an
|
|
45
|
+
* installed file its toolkit does not ship, and a binary older than the one
|
|
46
|
+
* that installed the file cannot tell a retired file from one added after
|
|
47
|
+
* its own release, so this is what it reads before deleting anything.
|
|
48
|
+
* Absent on a stamp written before the field existed.
|
|
49
|
+
*/
|
|
50
|
+
readonly version?: string
|
|
41
51
|
readonly syncedAt: string
|
|
42
52
|
readonly files: DomainHashes
|
|
43
53
|
/**
|
|
@@ -178,6 +188,14 @@ function readStampFile(path: string): Stamp | undefined {
|
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
|
|
191
|
+
/** The newest canon release that has written this domain, when one was recorded. */
|
|
192
|
+
export function stampedVersion(
|
|
193
|
+
stamp: Stamp | undefined,
|
|
194
|
+
domain: StampDomain,
|
|
195
|
+
): string | undefined {
|
|
196
|
+
return stamp?.domains[domain]?.version
|
|
197
|
+
}
|
|
198
|
+
|
|
181
199
|
/** The revision this domain last synced from, which bounds its upstream range. */
|
|
182
200
|
export function stampedCommit(
|
|
183
201
|
stamp: Stamp | undefined,
|
|
@@ -245,9 +263,11 @@ async function putDomain(
|
|
|
245
263
|
const previous = readStamp(target)
|
|
246
264
|
const previousRecord = previous?.domains[source.domain]
|
|
247
265
|
const commit = await toolkitCommit(source.toolkitRoot)
|
|
266
|
+
const version = newerVersion(readInstalled().version, previousRecord?.version)
|
|
248
267
|
|
|
249
268
|
const record: DomainStamp = {
|
|
250
269
|
...(commit === undefined ? {} : { commit }),
|
|
270
|
+
...(version === undefined ? {} : { version }),
|
|
251
271
|
syncedAt: now.toISOString(),
|
|
252
272
|
files: payload.files ?? previousRecord?.files ?? {},
|
|
253
273
|
...resolveChainField(payload.chain, previousRecord?.chain),
|
|
@@ -304,6 +324,27 @@ async function readCommit(root: string): Promise<string | undefined> {
|
|
|
304
324
|
: undefined
|
|
305
325
|
}
|
|
306
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Keeps the newer of the running release and the one already recorded, so an
|
|
329
|
+
* older binary syncing after a newer one cannot lower the mark and let a later
|
|
330
|
+
* run delete a rule the newer release installed. An unparseable side loses to
|
|
331
|
+
* a parseable one, and a recorded value survives an unreadable manifest.
|
|
332
|
+
*/
|
|
333
|
+
function newerVersion(
|
|
334
|
+
running: string | undefined,
|
|
335
|
+
recorded: string | undefined,
|
|
336
|
+
): string | undefined {
|
|
337
|
+
if (running === undefined) return recorded
|
|
338
|
+
if (recorded === undefined) return running
|
|
339
|
+
|
|
340
|
+
const parsedRunning = parseVersion(running)
|
|
341
|
+
const parsedRecorded = parseVersion(recorded)
|
|
342
|
+
if (parsedRecorded === undefined) return running
|
|
343
|
+
if (parsedRunning === undefined) return recorded
|
|
344
|
+
|
|
345
|
+
return compareVersions(parsedRecorded, parsedRunning) > 0 ? recorded : running
|
|
346
|
+
}
|
|
347
|
+
|
|
307
348
|
/**
|
|
308
349
|
* A write naming no chain keeps the domain's previous one rather than dropping
|
|
309
350
|
* it, since `writeStamp` and `writeChainStamp` each touch one half of a
|
|
@@ -365,6 +406,9 @@ function isDomainStamp(value: unknown): value is DomainStamp {
|
|
|
365
406
|
if (value.commit !== undefined && typeof value.commit !== 'string') {
|
|
366
407
|
return false
|
|
367
408
|
}
|
|
409
|
+
if (value.version !== undefined && typeof value.version !== 'string') {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
368
412
|
if (value.chain !== undefined && !isStringArray(value.chain)) return false
|
|
369
413
|
|
|
370
414
|
return Object.values(value.files).every((hash) => typeof hash === 'string')
|