@erclx/canon 5.36.0 → 5.37.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/api-design/SKILL.md +6 -0
- package/claude/skills/backlog-triage/SKILL.md +7 -7
- package/claude/skills/build-in-slices/SKILL.md +6 -0
- package/claude/skills/build-in-slices/references/adopted.md +3 -0
- package/claude/skills/canon-cli/SKILL.md +3 -0
- package/claude/skills/canon-feedback/SKILL.md +5 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +3 -0
- package/claude/skills/canvas-board/SKILL.md +3 -0
- package/claude/skills/canvas-board/references/canvas.md +12 -0
- package/claude/skills/ci-workflow/SKILL.md +5 -0
- package/claude/skills/code-craft/SKILL.md +6 -0
- package/claude/skills/context-fold/references/context.md +48 -34
- package/claude/skills/create-rule/SKILL.md +11 -9
- package/claude/skills/create-rule/references/rule.md +6 -0
- package/claude/skills/create-skill/SKILL.md +11 -2
- package/claude/skills/create-skill/references/skill-disclosure.md +26 -0
- package/claude/skills/create-skill/references/skill-paths.md +3 -0
- package/claude/skills/create-skill/references/skill-reference.md +12 -0
- package/claude/skills/create-skill/references/skill.md +25 -24
- package/claude/skills/deprecation-migration/SKILL.md +6 -0
- package/claude/skills/deprecation-migration/references/adopted.md +3 -0
- package/claude/skills/design-taste/SKILL.md +6 -0
- package/claude/skills/design-taste/references/craft.md +3 -0
- package/claude/skills/design-taste/references/preflight.md +16 -0
- package/claude/skills/docs-sync/references/docs.md +6 -0
- package/claude/skills/docs-sync/references/readme.md +15 -0
- package/claude/skills/draft-and-pick/SKILL.md +12 -0
- package/claude/skills/draft-doc/references/context.md +1 -1
- package/claude/skills/draft-doc/references/readme.md +3 -0
- package/claude/skills/draft-figure/references/mermaid.md +6 -0
- package/claude/skills/draft-slides/SKILL.md +3 -0
- package/claude/skills/git-branch/references/branch.md +16 -11
- package/claude/skills/git-commit/references/commit.md +19 -11
- package/claude/skills/git-commit/references/versioning.md +9 -0
- package/claude/skills/git-issue/references/issue.md +12 -9
- package/claude/skills/git-pr/SKILL.md +5 -0
- package/claude/skills/git-pr/references/pr.md +24 -6
- package/claude/skills/git-ship/SKILL.md +3 -0
- package/claude/skills/git-split/SKILL.md +6 -0
- package/claude/skills/git-stage/SKILL.md +3 -0
- package/claude/skills/git-worktree/SKILL.md +6 -6
- package/claude/skills/markdown-craft/references/markdown.md +24 -6
- package/claude/skills/plan-feature/SKILL.md +9 -0
- package/claude/skills/plan-feature/references/plan.md +20 -8
- package/claude/skills/plan-groundwork/SKILL.md +6 -0
- package/claude/skills/plan-groundwork/references/groundwork.md +10 -7
- package/claude/skills/plan-intake/references/intake.md +6 -6
- package/claude/skills/read-frames/SKILL.md +3 -0
- package/claude/skills/review-craft/SKILL.md +3 -0
- package/claude/skills/review-ui/SKILL.md +6 -3
- package/claude/skills/role-orchestrator/SKILL.md +3 -0
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +7 -4
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +3 -0
- package/claude/skills/role-planner/SKILL.md +3 -0
- package/claude/skills/role-worker/SKILL.md +12 -0
- package/claude/skills/search-craft/SKILL.md +3 -0
- package/claude/skills/systematic-debugging/SKILL.md +6 -0
- package/claude/skills/target-check/references/domains.md +5 -0
- package/claude/skills/target-setup/SKILL.md +5 -0
- package/claude/skills/task-board/references/tasks.md +9 -6
- package/claude/skills/task-complete/references/archive.md +3 -0
- package/claude/skills/teach-workspace/SKILL.md +3 -0
- package/claude/skills/teach-workspace/references/glossary.md +3 -0
- package/claude/skills/teach-workspace/references/lesson-build.md +3 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +3 -0
- package/claude/skills/teach-workspace/references/teach.md +8 -8
- package/claude/skills/test-craft/SKILL.md +5 -0
- package/claude/skills/video-craft/SKILL.md +6 -0
- package/claude/skills/write-human/SKILL.md +9 -0
- package/docs/agents/capture.md +2 -0
- package/docs/agents/commands.md +54 -54
- package/docs/agents/context-audit-checks.md +6 -0
- package/docs/agents/context-audit.md +8 -8
- package/docs/agents/context-classify.md +2 -0
- package/docs/agents/drift-surfaces.md +5 -5
- package/docs/agents/gate.md +6 -7
- package/docs/agents/index.md +1 -1
- package/docs/agents/install-and-sync.md +2 -2
- package/docs/agents/markdown-audit-weight.md +11 -3
- package/docs/agents/markdown-audit.md +2 -2
- package/docs/agents/pr-frames.md +17 -3
- package/docs/agents/sessions-transfer.md +8 -6
- package/docs/agents/skills-audit.md +9 -1
- package/docs/target/scenarios.md +1 -1
- package/docs/target/standards-moved.md +20 -18
- package/docs/workflow/ai-workflow.md +3 -0
- package/docs/workflow/visual-design-workflow.md +5 -0
- package/package.json +2 -2
- package/src/cli/help.ts +1 -1
- package/src/commands/context.ts +29 -4
- package/src/commands/gate.ts +1 -8
- package/src/commands/markdown.ts +52 -0
- package/src/commands/pr.ts +17 -10
- package/src/context/audit.ts +83 -0
- package/src/context/classify/prompts.ts +6 -1
- package/src/context/gate.ts +6 -3
- package/src/gate/measures.ts +186 -1
- package/src/gate/sequencer.ts +0 -4
- package/src/gate/stages.ts +32 -27
- package/src/git/files.ts +30 -0
- package/src/gov/adapter.ts +2 -2
- package/src/gov/install.ts +2 -2
- package/src/gov/list.ts +1 -1
- package/src/gov/payload.ts +1 -1
- package/src/gov/stacks.ts +1 -1
- package/src/markdown/gate.ts +10 -0
- package/src/markdown/growth.ts +43 -0
- package/src/markdown/stacks.ts +74 -0
- package/src/markdown/structure.ts +72 -4
- package/src/migrate/rule-layout.ts +12 -12
- package/src/pr/frames.ts +66 -1
- package/src/sync/check.ts +23 -23
- package/tooling/astro/configs/tsconfig.json +1 -1
- package/tooling/astro/reference.md +2 -2
- package/tooling/base/configs/scripts/verify.json +1 -1
- package/tooling/base/configs/scripts/verify.ts +2 -8
- package/tooling/base/reference.md +10 -10
- package/tooling/base/seeds/canon/context/ci.md +4 -4
- package/tooling/base/seeds/canon/context/development.md +9 -9
- package/tooling/claude/seeds/.claude/hooks/scratch-guard.sh +8 -0
- package/tooling/vite-react/configs/tsconfig.json +1 -1
- package/tooling/vite-react/configs/vitest.config.ts +1 -1
- package/tooling/vite-react/reference.md +2 -2
- package/tooling/web/configs/src/test/setup.ts +1 -1
- package/tooling/web/manifest.toml +3 -3
- package/tooling/web/reference.md +1 -1
package/src/gate/measures.ts
CHANGED
|
@@ -18,9 +18,18 @@ import {
|
|
|
18
18
|
import { familyKeys } from '@/claude/skills-families'
|
|
19
19
|
import { measureDesignProse } from '@/design/budget'
|
|
20
20
|
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
listChangedFiles,
|
|
23
|
+
listFilesAtRef,
|
|
24
|
+
listRepositoryFiles,
|
|
25
|
+
readFileAtRef,
|
|
26
|
+
resolveBaseRef,
|
|
27
|
+
} from '@/git/files'
|
|
22
28
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
23
29
|
import { resolveMarkdown } from '@/markdown/files'
|
|
30
|
+
import { grownBlocks, normalizeProse } from '@/markdown/growth'
|
|
31
|
+
import { bodyLines } from '@/markdown/scan'
|
|
32
|
+
import { bulletStacks as findBulletStacks } from '@/markdown/stacks'
|
|
24
33
|
import { CHECKPOINTS } from '@/markdown/structure'
|
|
25
34
|
import {
|
|
26
35
|
isShippedCorpus,
|
|
@@ -501,6 +510,182 @@ export const documentCeiling: Measure = async (ctx) => {
|
|
|
501
510
|
}
|
|
502
511
|
}
|
|
503
512
|
|
|
513
|
+
/**
|
|
514
|
+
* The tracked documents a bullet run is held against: the context entries, the
|
|
515
|
+
* docs pages, and the skill bodies and references of both skill trees.
|
|
516
|
+
*
|
|
517
|
+
* Rules and the requirement records stay out, since a rule is one imperative
|
|
518
|
+
* per bullet and a requirement is a record of its own. Named here and nowhere
|
|
519
|
+
* else, so the stage and its tests read one scope.
|
|
520
|
+
*/
|
|
521
|
+
const STACK_SCOPE = [
|
|
522
|
+
/^canon\/context\/.+\.md$/,
|
|
523
|
+
/^docs\/.+\.md$/,
|
|
524
|
+
/^(?:claude|\.claude)\/skills\/[^/]+\/SKILL\.md$/,
|
|
525
|
+
/^(?:claude|\.claude)\/skills\/[^/]+\/references\/.+\.md$/,
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
/** Whether a path is one the bullet cap holds. */
|
|
529
|
+
export function inStackScope(rel: string): boolean {
|
|
530
|
+
return STACK_SCOPE.some((pattern) => pattern.test(rel))
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** The context entries alone, where length drifts because `context-fold` appends after every merge. */
|
|
534
|
+
const GROWTH_SCOPE = /^canon\/context\/.+\.md$/
|
|
535
|
+
|
|
536
|
+
/** Whether a path is one the growth check reads. */
|
|
537
|
+
export function inGrowthScope(rel: string): boolean {
|
|
538
|
+
return GROWTH_SCOPE.test(rel)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Every in-scope document against the five-bullet cap.
|
|
543
|
+
*
|
|
544
|
+
* The failure names each file with its longest run and where it opens. Read
|
|
545
|
+
* in-process rather than through `canon markdown audit`, whose exit code stays
|
|
546
|
+
* bans and dead links only, for the reason `documentCeiling` gives: the plan
|
|
547
|
+
* and groundwork skills run that audit on gitignored records, where a long
|
|
548
|
+
* list is not a defect.
|
|
549
|
+
*/
|
|
550
|
+
export const bulletStacks: Measure = async (ctx) => {
|
|
551
|
+
const files = await listRepositoryFiles(ctx.root)
|
|
552
|
+
if (files === undefined) {
|
|
553
|
+
return {
|
|
554
|
+
emissions: [],
|
|
555
|
+
unmeasured: 'git could not list the tree, so no bullet run was measured.',
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const scoped = files.filter(inStackScope)
|
|
560
|
+
const failing: { rel: string; count: number; line: number }[] = []
|
|
561
|
+
|
|
562
|
+
for (const rel of scoped) {
|
|
563
|
+
let source: string
|
|
564
|
+
try {
|
|
565
|
+
source = readFileSync(join(ctx.root, rel), 'utf8')
|
|
566
|
+
} catch {
|
|
567
|
+
continue
|
|
568
|
+
}
|
|
569
|
+
const runs = findBulletStacks(bodyLines(source))
|
|
570
|
+
if (runs.length === 0) continue
|
|
571
|
+
const longest = runs.reduce((a, b) => (b.count > a.count ? b : a))
|
|
572
|
+
failing.push({ rel, count: longest.count, line: longest.line })
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (failing.length === 0) {
|
|
576
|
+
return {
|
|
577
|
+
emissions: [
|
|
578
|
+
info(
|
|
579
|
+
`No run of more than 5 plain bullets across ${plural(scoped.length, 'document')}`,
|
|
580
|
+
),
|
|
581
|
+
],
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const where = failing
|
|
586
|
+
.sort((a, b) => b.count - a.count)
|
|
587
|
+
.map((f) => `${f.rel}:${f.line} ${f.count} bullets`)
|
|
588
|
+
.join(', ')
|
|
589
|
+
return {
|
|
590
|
+
emissions: [],
|
|
591
|
+
failure: `${plural(failing.length, 'document')} carrying a run of more than 5 plain bullets: ${where}. Split each run under H3 headings with a few bullets or prose beneath, or open every item with a bold label or a link.`,
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* A context entry's paragraph or top-level bullet past its checkpoint that
|
|
597
|
+
* this branch added or changed.
|
|
598
|
+
*
|
|
599
|
+
* A block fails when its text is absent from every context entry the merge
|
|
600
|
+
* base held, so an unchanged long block passes wherever it now sits and an
|
|
601
|
+
* edited or new one fails. Reading the whole base folder rather than the same
|
|
602
|
+
* path is what lets a block move into a sibling entry. No resolvable base
|
|
603
|
+
* reports unmeasured rather than a pass, since a missing base would otherwise
|
|
604
|
+
* read every block as new or none as new.
|
|
605
|
+
*
|
|
606
|
+
* Only context entries are held to it. Length drifts there because
|
|
607
|
+
* `context-fold` appends after every merge, and in docs and skills it stays the
|
|
608
|
+
* audit's advisory warning.
|
|
609
|
+
*/
|
|
610
|
+
export const paragraphGrowth: Measure = async (ctx) => {
|
|
611
|
+
const base = await resolveBaseRef(ctx.root)
|
|
612
|
+
if (base === undefined) {
|
|
613
|
+
return {
|
|
614
|
+
emissions: [],
|
|
615
|
+
unmeasured:
|
|
616
|
+
'no merge base resolved, so no paragraph or bullet was compared against it.',
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const changed = await listChangedFiles(ctx.root, base)
|
|
621
|
+
if (changed === undefined) {
|
|
622
|
+
return {
|
|
623
|
+
emissions: [],
|
|
624
|
+
unmeasured:
|
|
625
|
+
'git could not list the branch range, so no block was compared.',
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const grown: {
|
|
630
|
+
rel: string
|
|
631
|
+
kind: string
|
|
632
|
+
line: number
|
|
633
|
+
characters: number
|
|
634
|
+
}[] = []
|
|
635
|
+
let corpus: string | undefined
|
|
636
|
+
|
|
637
|
+
for (const rel of changed.filter(inGrowthScope)) {
|
|
638
|
+
let source: string
|
|
639
|
+
try {
|
|
640
|
+
source = readFileSync(join(ctx.root, rel), 'utf8')
|
|
641
|
+
} catch {
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const own = normalizeProse((await readFileAtRef(ctx.root, base, rel)) ?? '')
|
|
646
|
+
const first = grownBlocks(source, own)
|
|
647
|
+
if (first.length === 0) continue
|
|
648
|
+
|
|
649
|
+
if (corpus === undefined) {
|
|
650
|
+
const paths =
|
|
651
|
+
(await listFilesAtRef(ctx.root, base, 'canon/context')) ?? []
|
|
652
|
+
const texts = await Promise.all(
|
|
653
|
+
paths
|
|
654
|
+
.filter(inGrowthScope)
|
|
655
|
+
.map((path) => readFileAtRef(ctx.root, base, path)),
|
|
656
|
+
)
|
|
657
|
+
corpus = normalizeProse(texts.filter((t) => t !== undefined).join('\n'))
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
for (const block of grownBlocks(source, corpus)) {
|
|
661
|
+
grown.push({
|
|
662
|
+
rel,
|
|
663
|
+
kind: block.kind,
|
|
664
|
+
line: block.line,
|
|
665
|
+
characters: block.characters,
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (grown.length === 0) {
|
|
671
|
+
return {
|
|
672
|
+
emissions: [
|
|
673
|
+
info(
|
|
674
|
+
`No paragraph past ${CHECKPOINTS.paragraph} characters and no bullet past ${CHECKPOINTS.bullet} grew in a context entry`,
|
|
675
|
+
),
|
|
676
|
+
],
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const where = grown
|
|
681
|
+
.map((g) => `${g.rel}:${g.line} ${g.kind} ${g.characters} characters`)
|
|
682
|
+
.join(', ')
|
|
683
|
+
return {
|
|
684
|
+
emissions: [],
|
|
685
|
+
failure: `${plural(grown.length, 'block')} past the checkpoint added or changed on this branch: ${where}. Split each at a seam, or cut it under ${CHECKPOINTS.paragraph} characters for a paragraph and ${CHECKPOINTS.bullet} for a bullet.`,
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
504
689
|
/**
|
|
505
690
|
* Whether a path's stem, its basename up to the first `.`, repeats its
|
|
506
691
|
* immediate parent folder, either whole or as a `<folder>-` prefix.
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -113,8 +113,6 @@ export function readProcLoad(): LoadReading | undefined {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export interface GateContext extends MeasureContext {
|
|
116
|
-
/** Whether the format stage writes or checks. */
|
|
117
|
-
readonly write: boolean
|
|
118
116
|
/**
|
|
119
117
|
* The changed set a scoped stage is read against, or `undefined` where
|
|
120
118
|
* scoping is off and every stage runs.
|
|
@@ -444,8 +442,6 @@ export async function runStages(
|
|
|
444
442
|
const results: StageResult[] = []
|
|
445
443
|
|
|
446
444
|
for (const stage of stages) {
|
|
447
|
-
if (stage.when !== undefined && !stage.when({ write: ctx.write })) continue
|
|
448
|
-
|
|
449
445
|
const result = await runStage(stage, ctx)
|
|
450
446
|
results.push(result)
|
|
451
447
|
onResult?.(result)
|
package/src/gate/stages.ts
CHANGED
|
@@ -11,10 +11,12 @@ import {
|
|
|
11
11
|
captureStamps,
|
|
12
12
|
clientCommandCitations,
|
|
13
13
|
designProse,
|
|
14
|
+
bulletStacks,
|
|
14
15
|
documentCeiling,
|
|
15
16
|
folderEchoedNames,
|
|
16
17
|
markdownBans,
|
|
17
18
|
type Measure,
|
|
19
|
+
paragraphGrowth,
|
|
18
20
|
pluginManifests,
|
|
19
21
|
readmeCitations,
|
|
20
22
|
recordIdempotence,
|
|
@@ -81,11 +83,6 @@ export interface Stage {
|
|
|
81
83
|
readonly scope?: RegExp
|
|
82
84
|
/** What a scoped-out stage says, so a skip never reads as a pass. */
|
|
83
85
|
readonly skipped?: string
|
|
84
|
-
/**
|
|
85
|
-
* Whether this stage belongs in the run at all, read off the write grant
|
|
86
|
-
* rather than the changed set. Absent means it always belongs.
|
|
87
|
-
*/
|
|
88
|
-
readonly when?: (options: { readonly write: boolean }) => boolean
|
|
89
86
|
readonly checks: readonly Check[]
|
|
90
87
|
/**
|
|
91
88
|
* The machine lock this stage holds while its checks run, so one box runs it
|
|
@@ -144,28 +141,15 @@ const script = (name: string, failure: string): Check => ({
|
|
|
144
141
|
* whole set at once.
|
|
145
142
|
*/
|
|
146
143
|
export const STAGES: readonly Stage[] = [
|
|
147
|
-
{
|
|
148
|
-
id: 'format',
|
|
149
|
-
label: 'Formatting',
|
|
150
|
-
when: ({ write }) => write,
|
|
151
|
-
checks: [
|
|
152
|
-
{
|
|
153
|
-
kind: 'command',
|
|
154
|
-
argv: ['bun', 'run', 'format'],
|
|
155
|
-
failure: 'Format failed',
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
success: 'Format applied',
|
|
159
|
-
},
|
|
160
144
|
{
|
|
161
145
|
id: 'format-check',
|
|
162
146
|
label: 'Format check',
|
|
163
|
-
when: ({ write }) => !write,
|
|
164
147
|
checks: [
|
|
165
148
|
{
|
|
166
149
|
kind: 'command',
|
|
167
150
|
argv: ['bun', 'run', 'check:format'],
|
|
168
|
-
failure:
|
|
151
|
+
failure:
|
|
152
|
+
'Format check failed. Run bun run format and commit the result.',
|
|
169
153
|
},
|
|
170
154
|
],
|
|
171
155
|
success: 'Format check passed',
|
|
@@ -335,19 +319,23 @@ export const STAGES: readonly Stage[] = [
|
|
|
335
319
|
checks: [{ kind: 'measure', measure: unreferencedRules }],
|
|
336
320
|
},
|
|
337
321
|
{
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
322
|
+
// The facts the audit can answer from the files gate: a cited path that
|
|
323
|
+
// does not resolve, a required section an entry does not declare, an H2
|
|
324
|
+
// outside the closed set of four, and an index disagreeing with its folder.
|
|
325
|
+
// Its length, depth, bullet, table, and provenance findings are judgment
|
|
326
|
+
// thresholds, and failing a push on one would make the stage something to
|
|
327
|
+
// route around. The length readings that gate sit in their own stages
|
|
328
|
+
// below, apart from the audit's exit code: the whole-document ceiling, the
|
|
329
|
+
// bullet cap, and the growth of a paragraph or a bullet past its
|
|
330
|
+
// checkpoint.
|
|
343
331
|
id: 'context-citations',
|
|
344
332
|
label: 'Context citations',
|
|
345
333
|
checks: [
|
|
346
334
|
{
|
|
347
335
|
kind: 'cli',
|
|
348
|
-
argv: ['context', 'audit', '--
|
|
336
|
+
argv: ['context', 'audit', '--gate'],
|
|
349
337
|
failure:
|
|
350
|
-
'A cited context path does not resolve. Run bun src/cli.ts context audit.',
|
|
338
|
+
'A cited context path does not resolve, an entry lacks a required section or carries an H2 outside Overview, Layout, Decisions, and Gotchas, or an index disagrees with its folder. Run bun src/cli.ts context audit --gate.',
|
|
351
339
|
},
|
|
352
340
|
],
|
|
353
341
|
success: 'Context citations resolve',
|
|
@@ -366,6 +354,23 @@ export const STAGES: readonly Stage[] = [
|
|
|
366
354
|
label: 'Document ceiling',
|
|
367
355
|
checks: [{ kind: 'measure', measure: documentCeiling }],
|
|
368
356
|
},
|
|
357
|
+
{
|
|
358
|
+
// Unscoped, since any branch can add a bullet to any entry. A run of more
|
|
359
|
+
// than 5 plain bullets fails the push in the context entries, the docs
|
|
360
|
+
// pages, and the skill bodies and references. Apart from the audit's exit
|
|
361
|
+
// code for the reason the Document ceiling stage above states.
|
|
362
|
+
id: 'bullet-stacks',
|
|
363
|
+
label: 'Bullet stacks',
|
|
364
|
+
checks: [{ kind: 'measure', measure: bulletStacks }],
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
// Context entries only, where length drifts. A paragraph past 700
|
|
368
|
+
// characters or a top-level bullet past 250 fails when this branch added or
|
|
369
|
+
// changed it, and an unchanged one passes until it is edited.
|
|
370
|
+
id: 'paragraph-growth',
|
|
371
|
+
label: 'Paragraph growth',
|
|
372
|
+
checks: [{ kind: 'measure', measure: paragraphGrowth }],
|
|
373
|
+
},
|
|
369
374
|
{
|
|
370
375
|
// Unscoped, since any branch can add a file anywhere and one git ls-files
|
|
371
376
|
// costs nothing. Reads paths rather than markdown, so it sits here rather
|
package/src/git/files.ts
CHANGED
|
@@ -165,3 +165,33 @@ export async function listRepositoryFiles(
|
|
|
165
165
|
|
|
166
166
|
return [...new Set([...tracked, ...untracked])].sort()
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A file as `ref` holds it, or undefined where the ref holds no such path or
|
|
171
|
+
* git cannot answer. A path absent at the ref and a failed read are the same
|
|
172
|
+
* answer for a caller asking what the copy before this branch said.
|
|
173
|
+
*/
|
|
174
|
+
export async function readFileAtRef(
|
|
175
|
+
root: string,
|
|
176
|
+
ref: string,
|
|
177
|
+
rel: string,
|
|
178
|
+
): Promise<string | undefined> {
|
|
179
|
+
return git(root, ['show', `${ref}:${rel}`])
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every path under `prefix` that `ref` holds, or undefined where git cannot answer. */
|
|
183
|
+
export async function listFilesAtRef(
|
|
184
|
+
root: string,
|
|
185
|
+
ref: string,
|
|
186
|
+
prefix: string,
|
|
187
|
+
): Promise<string[] | undefined> {
|
|
188
|
+
const listed = await git(root, [
|
|
189
|
+
'ls-tree',
|
|
190
|
+
'-r',
|
|
191
|
+
'--name-only',
|
|
192
|
+
ref,
|
|
193
|
+
'--',
|
|
194
|
+
prefix,
|
|
195
|
+
])
|
|
196
|
+
return listed === undefined ? undefined : listed.split('\n').filter(Boolean)
|
|
197
|
+
}
|
package/src/gov/adapter.ts
CHANGED
|
@@ -98,8 +98,8 @@ export function createGovAdapter(root: string): SyncAdapter {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* Where the target already holds a rule, in whichever
|
|
102
|
-
* successor held outside the source's
|
|
101
|
+
* Where the target already holds a rule, in whichever rule folder. A
|
|
102
|
+
* successor held outside the source's folder stays where it is, the same way
|
|
103
103
|
* `locateSource` syncs a moved rule in place, rather than gaining a second copy.
|
|
104
104
|
*
|
|
105
105
|
* Holding the predecessor is the whole entitlement test, so no stamped chain
|
package/src/gov/install.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function canonRulesDir(target: string): string {
|
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* This repository's own toolkit-only rules, installed beside `canon/` rather
|
|
31
|
-
* than interleaved into its
|
|
31
|
+
* than interleaved into its folders. No target ever holds this folder.
|
|
32
32
|
*/
|
|
33
33
|
export function installedInternalRulesDir(target: string): string {
|
|
34
34
|
return join(installedRulesDir(target), 'internal')
|
|
@@ -134,7 +134,7 @@ export function lookupRules(
|
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
136
|
* Copies each rule into the subdirectory it was authored in, so the installed
|
|
137
|
-
* tree keeps the
|
|
137
|
+
* tree keeps the rule folder structure `governance/rules/` carries. Returns the
|
|
138
138
|
* target-relative paths the timeline prints.
|
|
139
139
|
*
|
|
140
140
|
* `destSubdir` picks which wrapper the rule lands under: `canon` for every
|
package/src/gov/list.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function buildStackEntries(root: string): StackEntry[] {
|
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
76
|
* Reads the catalog straight off the source tree. A rule's domain is the
|
|
77
|
-
* subdirectory it sits in, which is the
|
|
77
|
+
* subdirectory it sits in, which is the rule folder grouping install preserves, and
|
|
78
78
|
* its `detect` entry is the evidence that proposes it, null for a rule
|
|
79
79
|
* `detect.toml` does not name.
|
|
80
80
|
*/
|
package/src/gov/payload.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { stripFrontmatter } from '@/markdown/frontmatter'
|
|
|
5
5
|
/**
|
|
6
6
|
* Lists installed rules in the order the payload concatenates them. Sorting by
|
|
7
7
|
* path keeps the numeric filename prefixes ordered inside each subdirectory,
|
|
8
|
-
* which is how the rule
|
|
8
|
+
* which is how the rule folders are meant to read.
|
|
9
9
|
*/
|
|
10
10
|
export function listRuleFiles(rulesDir: string): string[] {
|
|
11
11
|
return [
|
package/src/gov/stacks.ts
CHANGED
|
@@ -163,7 +163,7 @@ export function unreferencedRules(root: string): string[] {
|
|
|
163
163
|
* does not hold. `resolveRules` already walks a stack's `extends` ancestors,
|
|
164
164
|
* so reading its leaf entry is enough; no second walk resolves the chain
|
|
165
165
|
* itself. A stack the toolkit no longer ships resolves to nothing rather than
|
|
166
|
-
* throwing, the same way `readNewRules`'s
|
|
166
|
+
* throwing, the same way `readNewRules`'s folder fallback already treats it.
|
|
167
167
|
*
|
|
168
168
|
* A held rule's declared successor counts as held, since the sync that
|
|
169
169
|
* renames the old file installs it.
|
package/src/markdown/gate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ColonFinding } from '@/markdown/colons'
|
|
|
2
2
|
import type { HeadingCaseFinding } from '@/markdown/heading-case'
|
|
3
3
|
import type { LinkFinding } from '@/markdown/links'
|
|
4
4
|
import type { BanFinding } from '@/markdown/scan'
|
|
5
|
+
import type { StackFinding } from '@/markdown/stacks'
|
|
5
6
|
import type { StructureReport } from '@/markdown/structure'
|
|
6
7
|
|
|
7
8
|
export interface GateInput {
|
|
@@ -31,6 +32,15 @@ export interface GateInput {
|
|
|
31
32
|
* case, and gating it would need a names list and an escape marker.
|
|
32
33
|
*/
|
|
33
34
|
readonly headingCase: readonly HeadingCaseFinding[]
|
|
35
|
+
/**
|
|
36
|
+
* Every run of plain bullets past the cap, read by nothing here.
|
|
37
|
+
*
|
|
38
|
+
* The stage that gates it is `bullet-stacks` in `src/gate/stages.ts`, which
|
|
39
|
+
* reads a scope of tracked files. This audit also runs on plans and
|
|
40
|
+
* groundwork records kept out of version control, where a long list is not
|
|
41
|
+
* a defect, which is the reason `documentCeiling` gates apart from it too.
|
|
42
|
+
*/
|
|
43
|
+
readonly bulletStacks: readonly StackFinding[]
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { bodyLines } from '@/markdown/scan'
|
|
2
|
+
import {
|
|
3
|
+
CHECKPOINTS,
|
|
4
|
+
type Checkpoints,
|
|
5
|
+
type LongBlock,
|
|
6
|
+
longBlocks,
|
|
7
|
+
} from '@/markdown/structure'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Reduces text to the words a reader meets, so a block reflowed, re-marked, or
|
|
11
|
+
* moved under another heading still matches the copy it came from.
|
|
12
|
+
*
|
|
13
|
+
* List markers and emphasis marks are dropped because turning a bullet into a
|
|
14
|
+
* labelled bullet or a paragraph changes the shape of a statement and none of
|
|
15
|
+
* its words, which is the edit the sweep makes. A word changed is a text
|
|
16
|
+
* changed, and that is what the growth stage exists to see.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeProse(text: string): string {
|
|
19
|
+
return text
|
|
20
|
+
.replace(/^\s*(?:[-*+]|\d+\.)\s+/gm, '')
|
|
21
|
+
.replace(/[*_]/g, '')
|
|
22
|
+
.replace(/\s+/g, ' ')
|
|
23
|
+
.trim()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The paragraphs and top-level bullets of `source` past their checkpoints whose
|
|
28
|
+
* text appears nowhere in `base`.
|
|
29
|
+
*
|
|
30
|
+
* Containment rather than line matching, so an unchanged long block passes
|
|
31
|
+
* wherever it sits and an edited or new one fails. `base` is every document the
|
|
32
|
+
* caller counts as the copy it may have come from, already normalized, since a
|
|
33
|
+
* block moved into a sibling entry is as unchanged as one left in place.
|
|
34
|
+
*/
|
|
35
|
+
export function grownBlocks(
|
|
36
|
+
source: string,
|
|
37
|
+
base: string,
|
|
38
|
+
checkpoints: Checkpoints = CHECKPOINTS,
|
|
39
|
+
): LongBlock[] {
|
|
40
|
+
return longBlocks(bodyLines(source), checkpoints).filter(
|
|
41
|
+
(block) => !base.includes(normalizeProse(block.text)),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { BodyLine } from '@/markdown/scan'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The most plain bullets one run may carry. Past it a reader holds the list as
|
|
5
|
+
* a whole rather than item by item, and the shape wants prose, an H3 per item,
|
|
6
|
+
* or a split at the seam.
|
|
7
|
+
*/
|
|
8
|
+
export const CAP = 5
|
|
9
|
+
|
|
10
|
+
/** A top-level bullet marker followed by its content, at column zero only. */
|
|
11
|
+
const BULLET = /^[-*+][ \t]+(.*)$/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* An item opening with a bold label or a link names its own subject. A task
|
|
15
|
+
* box (`[ ]`, `[x]`) opens with a bracket too and is not a link, so a checklist
|
|
16
|
+
* counts as a plain run.
|
|
17
|
+
*/
|
|
18
|
+
const LABELED = /^(?:\*\*|__|\[(?![ xX]\]))/
|
|
19
|
+
|
|
20
|
+
export interface StackFinding {
|
|
21
|
+
/** The line of the run's first item, as an editor numbers it. */
|
|
22
|
+
readonly line: number
|
|
23
|
+
readonly count: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Run {
|
|
27
|
+
readonly line: number
|
|
28
|
+
count: number
|
|
29
|
+
labeled: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Every run of top-level bullets past `CAP`, in document order.
|
|
34
|
+
*
|
|
35
|
+
* A run is consecutive column-zero `-`, `*`, or `+` items. A blank line, an
|
|
36
|
+
* indented continuation, and a nested item do not break it, and any other
|
|
37
|
+
* column-zero line does, since a paragraph or heading between two lists makes
|
|
38
|
+
* them two. Fenced lines are skipped rather than breaking the run, so a code
|
|
39
|
+
* block inside an item does not split it. A run whose every item opens with a
|
|
40
|
+
* bold label or a link is exempt, because each item carries its own subject
|
|
41
|
+
* and reads as a table of entries rather than a list to hold.
|
|
42
|
+
*
|
|
43
|
+
* Numbered items do not count: a numbered list states an order the reader
|
|
44
|
+
* follows one step at a time.
|
|
45
|
+
*/
|
|
46
|
+
export function bulletStacks(lines: readonly BodyLine[]): StackFinding[] {
|
|
47
|
+
const found: StackFinding[] = []
|
|
48
|
+
let run: Run | undefined
|
|
49
|
+
|
|
50
|
+
const close = (): void => {
|
|
51
|
+
if (run && run.count > CAP && run.labeled < run.count) {
|
|
52
|
+
found.push({ line: run.line, count: run.count })
|
|
53
|
+
}
|
|
54
|
+
run = undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const { number, text, fenced } of lines) {
|
|
58
|
+
if (fenced) continue
|
|
59
|
+
if (text.trim() === '' || /^\s/.test(text)) continue
|
|
60
|
+
|
|
61
|
+
const match = BULLET.exec(text)
|
|
62
|
+
if (!match) {
|
|
63
|
+
close()
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
run ??= { line: number, count: 0, labeled: 0 }
|
|
68
|
+
run.count++
|
|
69
|
+
if (LABELED.test(match[1])) run.labeled++
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close()
|
|
73
|
+
return found
|
|
74
|
+
}
|
|
@@ -109,7 +109,7 @@ const SENTENCE_END = /[.!?]["'’”)\]]*(?=\s+(?:["'“(\[]*[A-Z]|`)|\s*$)/g
|
|
|
109
109
|
export const CHECKPOINTS = {
|
|
110
110
|
run: 40,
|
|
111
111
|
peerBullet: 130,
|
|
112
|
-
bullet:
|
|
112
|
+
bullet: 250,
|
|
113
113
|
paragraph: 700,
|
|
114
114
|
sentences: 4,
|
|
115
115
|
renderWidth: 80,
|
|
@@ -427,8 +427,28 @@ export function heavyBullets(
|
|
|
427
427
|
lines: readonly BodyLine[],
|
|
428
428
|
checkpoints: Checkpoints,
|
|
429
429
|
): BulletFinding[] {
|
|
430
|
-
|
|
431
|
-
|
|
430
|
+
return foldBullets(lines, checkpoints).map(({ line, characters }) => ({
|
|
431
|
+
line,
|
|
432
|
+
characters,
|
|
433
|
+
}))
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
interface FoldedBullet extends BulletFinding {
|
|
437
|
+
/** The bullet's source lines joined by the space a wrap would have carried. */
|
|
438
|
+
readonly text: string
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The walk `heavyBullets` reports from, keeping each past-checkpoint bullet's
|
|
443
|
+
* text so `longBlocks` can ask whether a base copy of it exists. One walk
|
|
444
|
+
* rather than two, for the reason `paragraphBlocks` is shared.
|
|
445
|
+
*/
|
|
446
|
+
function foldBullets(
|
|
447
|
+
lines: readonly BodyLine[],
|
|
448
|
+
checkpoints: Checkpoints,
|
|
449
|
+
): FoldedBullet[] {
|
|
450
|
+
const findings: FoldedBullet[] = []
|
|
451
|
+
let open: FoldedBullet | null = null
|
|
432
452
|
|
|
433
453
|
const close = (): void => {
|
|
434
454
|
if (open && open.characters > checkpoints.bullet) findings.push(open)
|
|
@@ -450,7 +470,11 @@ export function heavyBullets(
|
|
|
450
470
|
if (item) {
|
|
451
471
|
close()
|
|
452
472
|
if (item[1].length === 0) {
|
|
453
|
-
open = {
|
|
473
|
+
open = {
|
|
474
|
+
line: line.number,
|
|
475
|
+
characters: visibleText(text).length,
|
|
476
|
+
text,
|
|
477
|
+
}
|
|
454
478
|
}
|
|
455
479
|
continue
|
|
456
480
|
}
|
|
@@ -466,6 +490,7 @@ export function heavyBullets(
|
|
|
466
490
|
open = {
|
|
467
491
|
...open,
|
|
468
492
|
characters: open.characters + visibleText(text).length + 1,
|
|
493
|
+
text: `${open.text} ${text}`,
|
|
469
494
|
}
|
|
470
495
|
}
|
|
471
496
|
|
|
@@ -581,6 +606,49 @@ export function heavyParagraphs(
|
|
|
581
606
|
return findings
|
|
582
607
|
}
|
|
583
608
|
|
|
609
|
+
export interface LongBlock {
|
|
610
|
+
readonly kind: 'paragraph' | 'bullet'
|
|
611
|
+
readonly line: number
|
|
612
|
+
readonly characters: number
|
|
613
|
+
/** The block's source lines folded into one, as the paragraph and bullet folds read it. */
|
|
614
|
+
readonly text: string
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The paragraphs past the paragraph checkpoint and the top-level bullets past
|
|
619
|
+
* the bullet checkpoint, each with its text.
|
|
620
|
+
*
|
|
621
|
+
* Weight alone, never the sentence cap, since the growth stage asks about the
|
|
622
|
+
* two numbers the standard states in characters. The definitions are the
|
|
623
|
+
* audit's own, so a block this names and a block the weight report names are
|
|
624
|
+
* one set.
|
|
625
|
+
*/
|
|
626
|
+
export function longBlocks(
|
|
627
|
+
lines: readonly BodyLine[],
|
|
628
|
+
checkpoints: Checkpoints,
|
|
629
|
+
): LongBlock[] {
|
|
630
|
+
const blocks: LongBlock[] = []
|
|
631
|
+
|
|
632
|
+
for (const block of paragraphBlocks(lines)) {
|
|
633
|
+
const text = paragraphText(block)
|
|
634
|
+
const characters = visibleText(text).length
|
|
635
|
+
if (characters > checkpoints.paragraph) {
|
|
636
|
+
blocks.push({
|
|
637
|
+
kind: 'paragraph',
|
|
638
|
+
line: block[0].number,
|
|
639
|
+
characters,
|
|
640
|
+
text,
|
|
641
|
+
})
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
for (const bullet of foldBullets(lines, checkpoints)) {
|
|
646
|
+
blocks.push({ kind: 'bullet', ...bullet })
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return blocks.sort((a, b) => a.line - b.line)
|
|
650
|
+
}
|
|
651
|
+
|
|
584
652
|
/** Punctuation either side of a word, so an opener is compared on its letters. */
|
|
585
653
|
const WORD_EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
|
|
586
654
|
|