@erclx/canon 5.32.0 → 5.34.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/REQUIREMENT.md +1 -2
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/worktree-name.md +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
- package/claude/skills/backlog-triage/SKILL.md +1 -1
- package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
- package/claude/skills/canon-cli/SKILL.md +75 -32
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +11 -29
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
- package/claude/skills/{canvas → canvas-board}/SKILL.md +6 -3
- package/claude/skills/{canvas → canvas-board}/references/canvas.md +26 -3
- package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/context-fold/REQUIREMENT.md +25 -33
- package/claude/skills/context-fold/SKILL.md +25 -70
- package/claude/skills/context-fold/references/classify.md +3 -3
- package/claude/skills/context-fold/references/completion-report.md +4 -4
- package/claude/skills/context-fold/references/context-refresh.md +5 -5
- package/{standards → claude/skills/context-fold/references}/context.md +16 -5
- package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
- package/claude/skills/create-rule/SKILL.md +3 -3
- package/{standards → claude/skills/create-rule/references}/rule.md +15 -6
- package/claude/skills/create-skill/SKILL.md +2 -2
- package/{standards → claude/skills/create-skill/references}/skill.md +18 -15
- package/claude/skills/design-taste/REQUIREMENT.md +3 -3
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +3 -3
- package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
- package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
- package/claude/skills/draft-and-pick/SKILL.md +5 -5
- package/claude/skills/draft-doc/SKILL.md +6 -6
- package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
- package/claude/skills/draft-figure/SKILL.md +2 -2
- package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
- package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
- package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
- package/claude/skills/draft-identity/SKILL.md +2 -2
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/git-branch/SKILL.md +1 -1
- package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
- package/claude/skills/git-commit/SKILL.md +2 -2
- package/claude/skills/git-followup/REQUIREMENT.md +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/git-pr/SKILL.md +13 -13
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-pr/references/final-command.md +38 -14
- package/claude/skills/git-pr/references/labels.md +5 -1
- package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
- package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
- package/claude/skills/git-pr/references/task-number.md +1 -1
- package/claude/skills/git-ship/SKILL.md +15 -14
- package/claude/skills/git-split/SKILL.md +3 -3
- package/claude/skills/git-stage/SKILL.md +1 -1
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -1
- package/claude/skills/review-pr/SKILL.md +3 -3
- package/claude/skills/review-pr/references/body-variants.md +2 -2
- package/claude/skills/review-pr/references/key-changes.md +3 -3
- package/claude/skills/review-pr/references/post.md +4 -2
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +8 -6
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +53 -17
- package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +32 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
- package/claude/skills/role-planner/REQUIREMENT.md +2 -1
- package/claude/skills/role-planner/SKILL.md +13 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +9 -1
- package/claude/skills/session-map/SKILL.md +1 -1
- package/claude/skills/session-resume/REQUIREMENT.md +2 -2
- package/claude/skills/session-worktree/SKILL.md +5 -5
- package/claude/skills/target-setup/SKILL.md +10 -4
- package/claude/skills/target-setup/references/detect.md +20 -3
- package/claude/skills/task-board/REQUIREMENT.md +7 -12
- package/claude/skills/task-board/SKILL.md +5 -17
- package/claude/skills/task-board/references/tasks.md +2 -2
- package/claude/skills/task-complete/REQUIREMENT.md +47 -0
- package/claude/skills/task-complete/SKILL.md +86 -0
- package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
- package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
- package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
- package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
- package/claude/skills/teach-workspace/SKILL.md +8 -4
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
- package/claude/skills/teach-workspace/references/promotion.md +3 -3
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/docs/agents/canvas.md +6 -2
- package/docs/agents/commands.md +4 -3
- package/docs/agents/context-audit-checks.md +4 -4
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +7 -4
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +10 -11
- package/docs/agents/key-changes.md +5 -5
- package/docs/agents/label-coverage.md +11 -0
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/records-push.md +2 -2
- package/docs/agents/records-stale.md +12 -47
- package/docs/agents/records-validate.md +3 -7
- package/docs/agents/records.md +5 -27
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +4 -4
- package/docs/agents/scripting.md +2 -1
- package/docs/agents/skills-audit.md +1 -1
- package/docs/agents/tasks-plans.md +2 -2
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +10 -2
- package/docs/target/projects.md +4 -4
- package/docs/target/sync.md +1 -1
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/parallel-features.md +7 -7
- package/docs/workflow/skill-map-on-request.md +9 -9
- package/docs/workflow/skill-map.md +12 -11
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/detect.toml +201 -0
- package/governance/renames.toml +0 -11
- package/governance/rules/canon/context.md +13 -0
- package/governance/rules/claude/skill.md +17 -0
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/governance/stacks/base.toml +2 -4
- package/package.json +4 -4
- package/scripts/core/install-check.sh +3 -3
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -168
- package/src/audits/catalog.ts +0 -1
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +4 -0
- package/src/claude/cases/workflow.ts +6 -1
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +2 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/ui.ts +2 -3
- package/src/commands/canvas.ts +179 -1
- package/src/commands/claude.ts +1 -1
- package/src/commands/gov.ts +146 -16
- package/src/commands/labels.ts +326 -7
- package/src/commands/migrate.ts +6 -1
- package/src/commands/pr.ts +377 -2
- package/src/commands/records.ts +13 -399
- package/src/commands/sandbox.ts +3 -6
- package/src/commands/tasks.ts +3 -3
- package/src/context/audit.ts +5 -5
- package/src/context/folders.ts +1 -1
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/feedback/body.ts +26 -5
- package/src/gate/measures.ts +59 -45
- package/src/gate/sequencer.ts +56 -1
- package/src/gate/stages.ts +4 -11
- package/src/gov/detect.ts +301 -0
- package/src/gov/install.ts +2 -3
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +10 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/labels/apply.ts +39 -0
- package/src/labels/format.ts +3 -3
- package/src/labels/phase.ts +3 -3
- package/src/labels/sections.ts +43 -0
- package/src/migrate/skill-names.ts +20 -1
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/paths.ts +4 -4
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/records/backup.ts +16 -6
- package/src/records/stale-canonical.ts +31 -1
- package/src/records/validate.ts +2 -188
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +2 -6
- package/src/sandbox/equivalence.ts +19 -51
- package/src/sandbox/expect.ts +4 -4
- package/src/sandbox/provision.ts +13 -36
- package/src/sandbox/scenario.ts +26 -48
- package/src/sandbox/tree.ts +2 -6
- package/src/standards/read.ts +14 -1
- package/src/sync/git.ts +1 -1
- package/src/tasks/archive.ts +5 -5
- package/src/tasks/branch.ts +3 -3
- package/src/tasks/label.ts +1 -1
- package/src/tasks/record.ts +2 -2
- package/src/tasks/validate.ts +1 -1
- package/standards/index.md +0 -13
- package/standards/standard.md +1 -7
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/pull_request_template.md +2 -2
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
- package/governance/rules/standards/context.md +0 -14
- package/governance/rules/standards/design.md +0 -12
- package/governance/rules/standards/readme.md +0 -12
- package/governance/rules/standards/rule.md +0 -13
- package/governance/rules/standards/skill.md +0 -21
- package/governance/rules/standards/standard.md +0 -13
- package/scripts/config.sh +0 -11
- package/scripts/lib/gov.sh +0 -14
- package/scripts/lib/sandbox-fixtures.sh +0 -191
- package/scripts/lib/sandbox-git.sh +0 -125
- package/scripts/lib/sandbox-path.sh +0 -50
- package/scripts/sandbox-hook.sh +0 -99
- package/src/gov/standard-rules.ts +0 -179
- package/src/records/migrate.ts +0 -78
- package/src/records/stale.ts +0 -243
- package/src/sandbox/hooks.ts +0 -128
- /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
- /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
- /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
- /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
package/src/design/render.ts
CHANGED
|
@@ -105,7 +105,7 @@ interface Confidence {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
* The columns the confidence ratio reads, fixed by `
|
|
108
|
+
* The columns the confidence ratio reads, fixed by `claude/skills/canvas-board/references/design.md`. The
|
|
109
109
|
* first column of each table names its row, and `Multiplier` and `When used`
|
|
110
110
|
* restate what the row already carries, so none of them is something a source
|
|
111
111
|
* could anchor and none belongs in the denominator.
|
package/src/feedback/body.ts
CHANGED
|
@@ -18,13 +18,18 @@ export const REQUIRED_FIELDS = ['Surface', 'Observed', 'Proposed fix'] as const
|
|
|
18
18
|
* retire, and the validator would then enforce nothing.
|
|
19
19
|
*/
|
|
20
20
|
const FIELD_HEADING = /^###\s+(.+?)\s*$/
|
|
21
|
-
const FENCE = /^\s*(
|
|
21
|
+
const FENCE = /^\s*(`{3,}|~{3,})(.*)$/
|
|
22
|
+
|
|
23
|
+
interface OpenFence {
|
|
24
|
+
readonly marker: string
|
|
25
|
+
readonly length: number
|
|
26
|
+
}
|
|
22
27
|
|
|
23
28
|
export function parseSections(body: string): Map<string, string> {
|
|
24
29
|
const sections = new Map<string, string>()
|
|
25
30
|
let heading: string | undefined
|
|
26
31
|
let lines: string[] = []
|
|
27
|
-
let
|
|
32
|
+
let fence: OpenFence | undefined
|
|
28
33
|
|
|
29
34
|
const flush = (): void => {
|
|
30
35
|
if (heading === undefined) return
|
|
@@ -34,9 +39,25 @@ export function parseSections(body: string): Map<string, string> {
|
|
|
34
39
|
for (const line of body.split('\n')) {
|
|
35
40
|
// A Repro field routinely carries a fenced block, and a `###` comment
|
|
36
41
|
// inside one is shell rather than the next field. Reading it as a heading
|
|
37
|
-
// splits the section and drops everything under it.
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
// splits the section and drops everything under it. A Suggested shape
|
|
43
|
+
// wraps a template that holds its own fence in a longer one, so a fence
|
|
44
|
+
// closes only on the same marker at least as long, per CommonMark.
|
|
45
|
+
const fenceMatch = line.match(FENCE)
|
|
46
|
+
const run = fenceMatch?.[1]
|
|
47
|
+
if (run !== undefined) {
|
|
48
|
+
const marker = run.charAt(0)
|
|
49
|
+
const isBare = (fenceMatch?.[2] ?? '').trim() === ''
|
|
50
|
+
if (fence === undefined) {
|
|
51
|
+
fence = { marker, length: run.length }
|
|
52
|
+
} else if (
|
|
53
|
+
isBare &&
|
|
54
|
+
marker === fence.marker &&
|
|
55
|
+
run.length >= fence.length
|
|
56
|
+
) {
|
|
57
|
+
fence = undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const match = fence ? null : line.match(FIELD_HEADING)
|
|
40
61
|
if (match?.[1] === undefined) {
|
|
41
62
|
lines.push(line)
|
|
42
63
|
continue
|
package/src/gate/measures.ts
CHANGED
|
@@ -104,28 +104,6 @@ const info = (text: string): Emission => ({ kind: 'info', text })
|
|
|
104
104
|
const warn = (text: string): Emission => ({ kind: 'warn', text })
|
|
105
105
|
const output = (text: string): Emission => ({ kind: 'output', text })
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
* Rules no stack reaches, sorted the way `canon gov list` emits them.
|
|
109
|
-
* `260-shadcn` and `320-tanstack-query` are opt-in libraries a project may not
|
|
110
|
-
* want.
|
|
111
|
-
*
|
|
112
|
-
* `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
|
|
113
|
-
* `--add` until a Go or PHP stack ships, which `target-setup` reaches by
|
|
114
|
-
* matching a detected language against each rule's description.
|
|
115
|
-
*
|
|
116
|
-
* All are recorded here rather than in a config file: the list is what a
|
|
117
|
-
* reader compares a new arrival against, and a config file would absorb the
|
|
118
|
-
* arrival silently.
|
|
119
|
-
*/
|
|
120
|
-
export const GOV_EXPECTED_UNREFERENCED = [
|
|
121
|
-
'130-go',
|
|
122
|
-
'140-php',
|
|
123
|
-
'260-shadcn',
|
|
124
|
-
'320-tanstack-query',
|
|
125
|
-
'335-testing-go',
|
|
126
|
-
'336-testing-php',
|
|
127
|
-
]
|
|
128
|
-
|
|
129
107
|
/**
|
|
130
108
|
* Scenarios declaring no expectation, taken from `canon sandbox coverage`
|
|
131
109
|
* against a clean tree. Raising it is a deliberate edit that says which
|
|
@@ -170,16 +148,19 @@ function parseJson(payload: string): unknown {
|
|
|
170
148
|
}
|
|
171
149
|
|
|
172
150
|
/**
|
|
173
|
-
* A rule no stack
|
|
174
|
-
*
|
|
175
|
-
*
|
|
151
|
+
* A rule no stack reaches arrives at a project only through `canon gov
|
|
152
|
+
* detect`, so each one needs a `governance/detect.toml` entry or nothing ever
|
|
153
|
+
* proposes it. A key naming a retired rule fails too, since it would hide from
|
|
154
|
+
* the rename sweep that keys the file by basename.
|
|
155
|
+
*
|
|
156
|
+
* The check replaced a fixed list of expected unreferenced rules. Once the
|
|
157
|
+
* stacks are thin, that list is most of the catalog and would be rewritten on
|
|
158
|
+
* every new rule, which is a list nobody reads.
|
|
176
159
|
*
|
|
177
|
-
* The catalog is parsed here rather than piped through `bun --eval`,
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* reached. A sentinel string carried success past that. Parsing in process
|
|
182
|
-
* leaves the two states distinguishable with nothing to carry.
|
|
160
|
+
* The catalog is parsed here rather than piped through `bun --eval`, whose
|
|
161
|
+
* interpreter exits 0 when its script throws while stdin is a pipe, so a
|
|
162
|
+
* payload that is not JSON would print nothing and read as every rule
|
|
163
|
+
* reached. Parsing in process keeps the two states distinguishable.
|
|
183
164
|
*/
|
|
184
165
|
export const unreferencedRules: Measure = async (ctx) => {
|
|
185
166
|
const { exitCode, stdout } = await ctx.cli(['gov', 'list', '--json'])
|
|
@@ -190,34 +171,67 @@ export const unreferencedRules: Measure = async (ctx) => {
|
|
|
190
171
|
}
|
|
191
172
|
}
|
|
192
173
|
|
|
193
|
-
const record = parseJson(stdout) as
|
|
194
|
-
|
|
174
|
+
const record = parseJson(stdout) as
|
|
175
|
+
| { unreferenced?: unknown; rules?: unknown; staleDetect?: unknown }
|
|
176
|
+
| undefined
|
|
177
|
+
if (
|
|
178
|
+
!Array.isArray(record?.unreferenced) ||
|
|
179
|
+
!Array.isArray(record?.rules) ||
|
|
180
|
+
!Array.isArray(record?.staleDetect)
|
|
181
|
+
) {
|
|
195
182
|
return {
|
|
196
183
|
emissions: [],
|
|
197
184
|
unmeasured:
|
|
198
|
-
'The governance catalog carried no readable unreferenced list.',
|
|
185
|
+
'The governance catalog carried no readable unreferenced, rules, or staleDetect list.',
|
|
199
186
|
}
|
|
200
187
|
}
|
|
201
188
|
|
|
189
|
+
const detected = new Set(
|
|
190
|
+
record.rules
|
|
191
|
+
.filter(
|
|
192
|
+
(rule): rule is { name: string; detect: unknown } =>
|
|
193
|
+
typeof rule === 'object' &&
|
|
194
|
+
rule !== null &&
|
|
195
|
+
typeof (rule as { name?: unknown }).name === 'string',
|
|
196
|
+
)
|
|
197
|
+
.filter((rule) => rule.detect !== null && rule.detect !== undefined)
|
|
198
|
+
.map((rule) => rule.name),
|
|
199
|
+
)
|
|
202
200
|
const unreferenced = record.unreferenced.map(String)
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
const undetected = unreferenced.filter((rule) => !detected.has(rule))
|
|
202
|
+
const stale = record.staleDetect.map(String)
|
|
203
|
+
|
|
204
|
+
const emissions: Emission[] = []
|
|
205
|
+
if (undetected.length > 0) {
|
|
206
|
+
emissions.push(
|
|
207
|
+
warn(
|
|
208
|
+
`Reached by no stack and proposed by nothing: ${undetected.join(' ')}`,
|
|
209
|
+
),
|
|
210
|
+
warn(
|
|
211
|
+
'Name each in a stack, or add a table to governance/detect.toml with the deps or files that mark a project as wanting it.',
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
if (stale.length > 0) {
|
|
216
|
+
emissions.push(
|
|
217
|
+
warn(`governance/detect.toml names no live rule: ${stale.join(' ')}`),
|
|
218
|
+
warn('Rename the key to the rule it became, or delete the table.'),
|
|
219
|
+
)
|
|
205
220
|
}
|
|
206
221
|
|
|
207
|
-
|
|
208
|
-
if (listed === GOV_EXPECTED_UNREFERENCED.join(' ')) {
|
|
222
|
+
if (emissions.length > 0) {
|
|
209
223
|
return {
|
|
210
|
-
emissions
|
|
211
|
-
|
|
212
|
-
],
|
|
224
|
+
emissions,
|
|
225
|
+
failure: 'An opt-in rule has no route to a project',
|
|
213
226
|
}
|
|
214
227
|
}
|
|
215
228
|
|
|
216
229
|
return {
|
|
217
230
|
emissions: [
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
231
|
+
info(
|
|
232
|
+
unreferenced.length === 0
|
|
233
|
+
? 'Every rule is reached by a stack'
|
|
234
|
+
: `${plural(unreferenced.length, 'rule')} reached by no stack, each proposed by governance/detect.toml`,
|
|
221
235
|
),
|
|
222
236
|
],
|
|
223
237
|
}
|
|
@@ -317,7 +331,7 @@ export const designProse: Measure = async (ctx) => {
|
|
|
317
331
|
if (failures.length > 0) {
|
|
318
332
|
return {
|
|
319
333
|
emissions: [],
|
|
320
|
-
failure: `${failures.join(' ')} Cut to the budget in
|
|
334
|
+
failure: `${failures.join(' ')} Cut to the budget in claude/skills/canvas-board/references/design.md, moving the why to the context entry.`,
|
|
321
335
|
}
|
|
322
336
|
}
|
|
323
337
|
if (emissions.length === 0)
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { availableParallelism } from 'node:os'
|
|
1
3
|
import { join } from 'node:path'
|
|
2
4
|
import { execa } from 'execa'
|
|
3
5
|
import type {
|
|
@@ -44,6 +46,28 @@ export interface StageResult {
|
|
|
44
46
|
readonly queuedMs?: number
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
export interface LoadReading {
|
|
50
|
+
/** The 1-minute load average. */
|
|
51
|
+
readonly loadAverage: number
|
|
52
|
+
readonly cores: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reads the 1-minute load average from `/proc/loadavg`. A platform without
|
|
57
|
+
* it, as macOS, has no reading to give, so this answers `undefined` rather
|
|
58
|
+
* than throwing into a stage that has already failed.
|
|
59
|
+
*/
|
|
60
|
+
export function readProcLoad(): LoadReading | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const first = readFileSync('/proc/loadavg', 'utf8').split(' ')[0]
|
|
63
|
+
const loadAverage = Number.parseFloat(first)
|
|
64
|
+
if (!Number.isFinite(loadAverage)) return undefined
|
|
65
|
+
return { loadAverage, cores: availableParallelism() }
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
export interface GateContext extends MeasureContext {
|
|
48
72
|
/** Whether the format stage writes or checks. */
|
|
49
73
|
readonly write: boolean
|
|
@@ -58,6 +82,11 @@ export interface GateContext extends MeasureContext {
|
|
|
58
82
|
* wait and reads as a hung suite.
|
|
59
83
|
*/
|
|
60
84
|
readonly onQueue?: (label: string, holder: LockHolder) => void
|
|
85
|
+
/**
|
|
86
|
+
* Reads the machine load a failed locked stage ran under. Defaults to
|
|
87
|
+
* `/proc/loadavg`, and answers `undefined` where there is none.
|
|
88
|
+
*/
|
|
89
|
+
readonly readLoad?: () => LoadReading | undefined
|
|
61
90
|
}
|
|
62
91
|
|
|
63
92
|
export interface ChangedSet {
|
|
@@ -195,7 +224,11 @@ async function executeStage(
|
|
|
195
224
|
]
|
|
196
225
|
|
|
197
226
|
try {
|
|
198
|
-
const outcome =
|
|
227
|
+
const outcome = withLoadLine(
|
|
228
|
+
await runChecks(stage, ctx, queued),
|
|
229
|
+
stage,
|
|
230
|
+
ctx,
|
|
231
|
+
)
|
|
199
232
|
return held.waitedOn === undefined
|
|
200
233
|
? outcome
|
|
201
234
|
: { ...outcome, queuedMs: held.waitedMs }
|
|
@@ -204,6 +237,28 @@ async function executeStage(
|
|
|
204
237
|
}
|
|
205
238
|
}
|
|
206
239
|
|
|
240
|
+
/**
|
|
241
|
+
* The lock serializes suites and does nothing about the rest of the box, so a
|
|
242
|
+
* starved spawn test and a real failure read alike. The load beside the
|
|
243
|
+
* failure is what lets a report tell them apart.
|
|
244
|
+
*/
|
|
245
|
+
function withLoadLine(
|
|
246
|
+
outcome: StageOutcome,
|
|
247
|
+
stage: Stage,
|
|
248
|
+
ctx: GateContext,
|
|
249
|
+
): StageOutcome {
|
|
250
|
+
if (outcome.status !== 'failed') return outcome
|
|
251
|
+
|
|
252
|
+
const reading = (ctx.readLoad ?? readProcLoad)()
|
|
253
|
+
if (reading === undefined) return outcome
|
|
254
|
+
|
|
255
|
+
const text = `Load average ${reading.loadAverage} on ${reading.cores} cores when the ${stage.label} stage failed`
|
|
256
|
+
return {
|
|
257
|
+
...outcome,
|
|
258
|
+
emissions: [...outcome.emissions, { kind: 'info', text }],
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
207
262
|
async function runChecks(
|
|
208
263
|
stage: Stage,
|
|
209
264
|
ctx: GateContext,
|
package/src/gate/stages.ts
CHANGED
|
@@ -198,17 +198,11 @@ export const STAGES: readonly Stage[] = [
|
|
|
198
198
|
argv: ['gov', 'regen'],
|
|
199
199
|
failure: 'Consumed-copy regen failed',
|
|
200
200
|
},
|
|
201
|
-
{
|
|
202
|
-
kind: 'drift',
|
|
203
|
-
pathspec: 'governance/rules/standards',
|
|
204
|
-
failure:
|
|
205
|
-
'Standard-pointer rules drifted. Run bun run check and commit governance/rules/standards and .claude/rules.',
|
|
206
|
-
},
|
|
207
201
|
{
|
|
208
202
|
kind: 'drift',
|
|
209
203
|
pathspec: '.claude/rules',
|
|
210
204
|
failure:
|
|
211
|
-
'Consumed copies drifted. Run bun run check and commit
|
|
205
|
+
'Consumed copies drifted. Run bun run check and commit .claude/rules.',
|
|
212
206
|
},
|
|
213
207
|
],
|
|
214
208
|
success: 'Consumed copies clean',
|
|
@@ -334,10 +328,9 @@ export const STAGES: readonly Stage[] = [
|
|
|
334
328
|
success: 'Capability seeding clean',
|
|
335
329
|
},
|
|
336
330
|
{
|
|
337
|
-
// A stack
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// which `canon gov install` never reaches on its own.
|
|
331
|
+
// A rule no stack names reaches a target only when `canon gov detect`
|
|
332
|
+
// proposes it, so every such rule needs a governance/detect.toml entry,
|
|
333
|
+
// and every key there has to name a live rule.
|
|
341
334
|
id: 'unreferenced-rules',
|
|
342
335
|
label: 'Unreferenced rules',
|
|
343
336
|
checks: [{ kind: 'measure', measure: unreferencedRules }],
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
4
|
+
import { indexRuleSources } from '@/gov/install'
|
|
5
|
+
import { resolveRules } from '@/gov/stacks'
|
|
6
|
+
|
|
7
|
+
/** One table of `governance/detect.toml`. */
|
|
8
|
+
export interface DetectEntry {
|
|
9
|
+
readonly rule: string
|
|
10
|
+
readonly deps: readonly string[]
|
|
11
|
+
readonly files: readonly string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What matched for one proposal. `path` is the manifest a dependency was read
|
|
16
|
+
* from, or the path a file glob matched, so a reader can open the evidence.
|
|
17
|
+
*/
|
|
18
|
+
export interface Evidence {
|
|
19
|
+
readonly kind: 'dep' | 'file'
|
|
20
|
+
readonly match: string
|
|
21
|
+
readonly path: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Proposal {
|
|
25
|
+
readonly rule: string
|
|
26
|
+
readonly evidence: readonly Evidence[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DetectResult =
|
|
30
|
+
| { readonly ok: true; readonly proposals: readonly Proposal[] }
|
|
31
|
+
| { readonly ok: false; readonly missingStack: string }
|
|
32
|
+
|
|
33
|
+
/** How deep detection reads, matching `target-setup`'s marker walk. */
|
|
34
|
+
const MAX_DIRS = 2
|
|
35
|
+
|
|
36
|
+
/** Trees an outside-git walk skips, since each carries manifests of its own. */
|
|
37
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules', 'vendor', '.venv'])
|
|
38
|
+
|
|
39
|
+
export function detectFilePath(root: string): string {
|
|
40
|
+
return join(root, 'governance', 'detect.toml')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stringList(value: unknown): string[] {
|
|
44
|
+
return Array.isArray(value)
|
|
45
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
46
|
+
: []
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reads every table in file order. An absent file reads as no entries. */
|
|
50
|
+
export function loadDetectEntries(root: string): DetectEntry[] {
|
|
51
|
+
const path = detectFilePath(root)
|
|
52
|
+
if (!existsSync(path)) return []
|
|
53
|
+
|
|
54
|
+
const parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
55
|
+
string,
|
|
56
|
+
unknown
|
|
57
|
+
>
|
|
58
|
+
|
|
59
|
+
return Object.entries(parsed).flatMap(([rule, table]) => {
|
|
60
|
+
if (typeof table !== 'object' || table === null) return []
|
|
61
|
+
const fields = table as Record<string, unknown>
|
|
62
|
+
return [
|
|
63
|
+
{ rule, deps: stringList(fields.deps), files: stringList(fields.files) },
|
|
64
|
+
]
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Keys naming no rule under `governance/rules/`, sorted. */
|
|
69
|
+
export function staleDetectKeys(root: string): string[] {
|
|
70
|
+
const live = indexRuleSources(root)
|
|
71
|
+
return loadDetectEntries(root)
|
|
72
|
+
.map((entry) => entry.rule)
|
|
73
|
+
.filter((rule) => !live.has(rule))
|
|
74
|
+
.sort()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function depth(path: string): number {
|
|
78
|
+
return path.split('/').length - 1
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Files to depth two when the target is not a git checkout. */
|
|
82
|
+
function walkFiles(target: string): string[] {
|
|
83
|
+
const files: string[] = []
|
|
84
|
+
|
|
85
|
+
const visit = (rel: string): void => {
|
|
86
|
+
const abs = rel === '' ? target : join(target, rel)
|
|
87
|
+
for (const entry of readdirSync(abs, { withFileTypes: true })) {
|
|
88
|
+
const child = rel === '' ? entry.name : `${rel}/${entry.name}`
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
if (SKIPPED_DIRS.has(entry.name)) continue
|
|
91
|
+
if (depth(child) < MAX_DIRS + 1) visit(child)
|
|
92
|
+
} else if (entry.isFile()) {
|
|
93
|
+
files.push(child)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
visit('')
|
|
99
|
+
return files.sort()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The paths a file glob is matched against: every file to depth two, plus
|
|
104
|
+
* every folder on the way to any listed file, so an evidence folder such as
|
|
105
|
+
* `.github/workflows` matches without naming the file inside it.
|
|
106
|
+
*/
|
|
107
|
+
async function candidatePaths(target: string): Promise<string[]> {
|
|
108
|
+
const listed = (await listRepositoryFiles(target)) ?? walkFiles(target)
|
|
109
|
+
const paths = new Set<string>()
|
|
110
|
+
|
|
111
|
+
for (const file of listed) {
|
|
112
|
+
let dir = dirname(file)
|
|
113
|
+
while (dir !== '.') {
|
|
114
|
+
if (depth(dir) <= MAX_DIRS) paths.add(dir)
|
|
115
|
+
dir = dirname(dir)
|
|
116
|
+
}
|
|
117
|
+
if (depth(file) <= MAX_DIRS) paths.add(file)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [...paths].sort()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A PEP 508 requirement's distribution name, normalized per PEP 503. */
|
|
124
|
+
function pythonName(requirement: string): string {
|
|
125
|
+
const name = requirement.trim().split(/[\s<>=!~;[(@,]/)[0] ?? ''
|
|
126
|
+
return name.toLowerCase().replace(/[_.]+/g, '-')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function objectKeys(value: unknown): string[] {
|
|
130
|
+
return typeof value === 'object' && value !== null ? Object.keys(value) : []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function packageJsonDeps(text: string): string[] {
|
|
134
|
+
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
135
|
+
return [
|
|
136
|
+
...objectKeys(parsed.dependencies),
|
|
137
|
+
...objectKeys(parsed.devDependencies),
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function composerDeps(text: string): string[] {
|
|
142
|
+
const parsed = JSON.parse(text) as Record<string, unknown>
|
|
143
|
+
return [...objectKeys(parsed.require), ...objectKeys(parsed['require-dev'])]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pyprojectDeps(text: string): string[] {
|
|
147
|
+
const parsed = Bun.TOML.parse(text) as Record<string, unknown>
|
|
148
|
+
const project = (parsed.project ?? {}) as Record<string, unknown>
|
|
149
|
+
const requirements = [
|
|
150
|
+
...stringList(project.dependencies),
|
|
151
|
+
...Object.values(
|
|
152
|
+
(project['optional-dependencies'] ?? {}) as Record<string, unknown>,
|
|
153
|
+
).flatMap(stringList),
|
|
154
|
+
...Object.values(
|
|
155
|
+
(parsed['dependency-groups'] ?? {}) as Record<string, unknown>,
|
|
156
|
+
).flatMap(stringList),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
const poetry = ((parsed.tool ?? {}) as Record<string, unknown>).poetry as
|
|
160
|
+
| Record<string, unknown>
|
|
161
|
+
| undefined
|
|
162
|
+
const groups = (poetry?.group ?? {}) as Record<string, unknown>
|
|
163
|
+
const poetryNames = [
|
|
164
|
+
...objectKeys(poetry?.dependencies),
|
|
165
|
+
...objectKeys(poetry?.['dev-dependencies']),
|
|
166
|
+
...Object.values(groups).flatMap((group) =>
|
|
167
|
+
objectKeys((group as Record<string, unknown>)?.dependencies),
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
return [...requirements, ...poetryNames].map(pythonName)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function goModDeps(text: string): string[] {
|
|
175
|
+
const names: string[] = []
|
|
176
|
+
let inBlock = false
|
|
177
|
+
|
|
178
|
+
for (const raw of text.split('\n')) {
|
|
179
|
+
const line = raw.replace(/\/\/.*$/, '').trim()
|
|
180
|
+
if (inBlock) {
|
|
181
|
+
if (line === ')') inBlock = false
|
|
182
|
+
else if (line !== '') names.push(line.split(/\s+/)[0] ?? '')
|
|
183
|
+
} else if (/^require\s*\($/.test(line)) {
|
|
184
|
+
inBlock = true
|
|
185
|
+
} else if (line.startsWith('require ')) {
|
|
186
|
+
names.push(line.split(/\s+/)[1] ?? '')
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return names.filter(Boolean)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Python names are normalized on read, so `detect.toml` lists them lowercase. */
|
|
194
|
+
const READERS: Readonly<Record<string, (text: string) => string[]>> = {
|
|
195
|
+
'package.json': packageJsonDeps,
|
|
196
|
+
'composer.json': composerDeps,
|
|
197
|
+
'pyproject.toml': pyprojectDeps,
|
|
198
|
+
'go.mod': goModDeps,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Every dependency name a manifest to depth two declares, mapped to the
|
|
203
|
+
* manifests declaring it. A manifest that does not parse contributes nothing
|
|
204
|
+
* rather than failing the run, since one broken fixture folder should not cost
|
|
205
|
+
* a project every other proposal.
|
|
206
|
+
*/
|
|
207
|
+
function readDependencies(
|
|
208
|
+
target: string,
|
|
209
|
+
paths: readonly string[],
|
|
210
|
+
): Map<string, string[]> {
|
|
211
|
+
const deps = new Map<string, string[]>()
|
|
212
|
+
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
const reader = READERS[basename(path)]
|
|
215
|
+
if (reader === undefined) continue
|
|
216
|
+
|
|
217
|
+
let declared: string[]
|
|
218
|
+
try {
|
|
219
|
+
declared = reader(readFileSync(join(target, path), 'utf8'))
|
|
220
|
+
} catch {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
for (const dep of declared) {
|
|
225
|
+
const holders = deps.get(dep) ?? []
|
|
226
|
+
if (!holders.includes(path)) holders.push(path)
|
|
227
|
+
deps.set(dep, holders)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return deps
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A glob carrying no wildcard names one path rather than a pattern. */
|
|
235
|
+
function isLiteral(pattern: string): boolean {
|
|
236
|
+
return !/[*?[\]{}]/.test(pattern)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A literal pattern is also tested on disk, since evidence such as
|
|
241
|
+
* `.canon/canvas` sits in a folder a target's own `.gitignore` excludes, and
|
|
242
|
+
* the git listing never carries it.
|
|
243
|
+
*/
|
|
244
|
+
function matchEntry(
|
|
245
|
+
entry: DetectEntry,
|
|
246
|
+
target: string,
|
|
247
|
+
deps: ReadonlyMap<string, readonly string[]>,
|
|
248
|
+
paths: readonly string[],
|
|
249
|
+
): Evidence[] {
|
|
250
|
+
const evidence: Evidence[] = []
|
|
251
|
+
|
|
252
|
+
for (const dep of entry.deps) {
|
|
253
|
+
const holders = deps.get(dep) ?? []
|
|
254
|
+
for (const path of holders) evidence.push({ kind: 'dep', match: dep, path })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const pattern of entry.files) {
|
|
258
|
+
const glob = new Bun.Glob(pattern)
|
|
259
|
+
const hit =
|
|
260
|
+
paths.find((path) => glob.match(path)) ??
|
|
261
|
+
(isLiteral(pattern) && existsSync(join(target, pattern))
|
|
262
|
+
? pattern
|
|
263
|
+
: undefined)
|
|
264
|
+
if (hit !== undefined)
|
|
265
|
+
evidence.push({ kind: 'file', match: pattern, path: hit })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return evidence
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Proposes each opt-in rule whose evidence the target holds. A rule `stack`
|
|
273
|
+
* already resolves is left out, since proposing it would install nothing, and
|
|
274
|
+
* a key naming no live rule is skipped rather than proposed, which the
|
|
275
|
+
* `unreferenced-rules` gate stage reports on the toolkit side.
|
|
276
|
+
*/
|
|
277
|
+
export async function detectRules(
|
|
278
|
+
root: string,
|
|
279
|
+
target: string,
|
|
280
|
+
stack?: string,
|
|
281
|
+
): Promise<DetectResult> {
|
|
282
|
+
let covered = new Set<string>()
|
|
283
|
+
if (stack !== undefined) {
|
|
284
|
+
const resolution = resolveRules(root, stack)
|
|
285
|
+
if (!resolution.ok) return resolution
|
|
286
|
+
covered = new Set(resolution.rules)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const live = indexRuleSources(root)
|
|
290
|
+
const paths = await candidatePaths(target)
|
|
291
|
+
const deps = readDependencies(target, paths)
|
|
292
|
+
|
|
293
|
+
const proposals = loadDetectEntries(root)
|
|
294
|
+
.filter((entry) => live.has(entry.rule) && !covered.has(entry.rule))
|
|
295
|
+
.flatMap((entry) => {
|
|
296
|
+
const evidence = matchEntry(entry, target, deps, paths)
|
|
297
|
+
return evidence.length === 0 ? [] : [{ rule: entry.rule, evidence }]
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
return { ok: true, proposals }
|
|
301
|
+
}
|
package/src/gov/install.ts
CHANGED
|
@@ -66,9 +66,8 @@ export function installedRuleNames(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* subdirectory and installs flat.
|
|
69
|
+
* A rule sitting directly under `governance/rules/` has no subdirectory and
|
|
70
|
+
* installs flat.
|
|
72
71
|
*/
|
|
73
72
|
export function ruleSubdir(src: string, rulesRoot: string): string {
|
|
74
73
|
const subdir = dirname(relative(rulesRoot, src))
|