@erclx/canon 4.113.0 → 4.115.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/codebase-layout/SKILL.md +1 -1
- package/claude/skills/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +3 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/teach-workspace/SKILL.md +5 -3
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +2 -2
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/install-and-sync.md +2 -0
- package/docs/agents/records.md +22 -15
- package/docs/agents/teach.md +12 -4
- package/docs/target-projects.md +11 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/rules/lang/130-go.md +40 -0
- package/governance/rules/lang/140-php.md +31 -0
- package/governance/rules/lib/335-testing-go.md +35 -0
- package/governance/rules/lib/336-testing-php.md +30 -0
- package/governance/rules/lib/360-security-server.md +2 -0
- package/governance/rules/lib/370-database.md +2 -0
- package/governance/rules/ui/410-a11y.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/stacks/nextjs.toml +2 -0
- package/governance/stacks/react.toml +1 -1
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/records.ts +18 -1
- package/src/commands/teach.ts +4 -1
- package/src/commands/tooling.ts +25 -2
- package/src/context/audit.ts +54 -18
- package/src/design/components.ts +17 -0
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +13 -2
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +278 -34
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/standards/teach.md +13 -12
- package/tooling/base/configs/.husky/post-merge +5 -1
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/web/configs/scripts/verify.sh +32 -15
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Enforce Go naming, error wrapping, context passing, and package boundaries
|
|
3
|
+
paths:
|
|
4
|
+
- '**/*.go'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Go standards
|
|
8
|
+
|
|
9
|
+
## Casing conventions
|
|
10
|
+
|
|
11
|
+
- Use `MixedCaps` for exported names and `mixedCaps` for unexported ones. Never use underscores in a Go identifier.
|
|
12
|
+
- Name packages with one short lowercase word. Do not use `util`, `common`, or `helpers` as a package name.
|
|
13
|
+
- Do not repeat the package name in an exported identifier (`user.New`, not `user.NewUser`).
|
|
14
|
+
- Keep initialisms in one case (`userID`, `HTTPClient`), never `userId` or `HttpClient`.
|
|
15
|
+
|
|
16
|
+
## Errors
|
|
17
|
+
|
|
18
|
+
- Return an error as the last result value.
|
|
19
|
+
- Wrap an error with `fmt.Errorf("...: %w", err)` when adding context.
|
|
20
|
+
- Match an error with `errors.Is` or `errors.As`. Never compare `err.Error()` strings.
|
|
21
|
+
- Never discard an error with `_`.
|
|
22
|
+
- Do not `panic` for a failure a caller can expect. Reserve it for a broken invariant.
|
|
23
|
+
|
|
24
|
+
## Context
|
|
25
|
+
|
|
26
|
+
- Pass `context.Context` as the first parameter, named `ctx`.
|
|
27
|
+
- Never store a `context.Context` in a struct field.
|
|
28
|
+
- Do not pass a `nil` context. Use `context.TODO()` where none is available yet.
|
|
29
|
+
|
|
30
|
+
## Interfaces
|
|
31
|
+
|
|
32
|
+
- Declare an interface in the package that consumes it, not the one that implements it.
|
|
33
|
+
- Keep an interface to the methods its consumer calls.
|
|
34
|
+
- Accept interfaces and return concrete types.
|
|
35
|
+
|
|
36
|
+
## Packages
|
|
37
|
+
|
|
38
|
+
- Place code no other module may import under `internal/`.
|
|
39
|
+
- Do not perform side effects in `init`. Wire dependencies explicitly from `main`.
|
|
40
|
+
- Do not hold mutable state in a package-level variable.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Enforce PHP strict types, PSR-4 layout, immutable data shapes, and exception handling
|
|
3
|
+
paths:
|
|
4
|
+
- '**/*.php'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# PHP standards
|
|
8
|
+
|
|
9
|
+
## Strictness
|
|
10
|
+
|
|
11
|
+
- Open every file with `declare(strict_types=1);`.
|
|
12
|
+
- Declare native parameter, return, and property types. Do not rely on a docblock type where a native one exists.
|
|
13
|
+
- Compare with `===` and `!==`. Never use loose `==` or `!=`.
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
- Autoload classes through PSR-4 from `composer.json`. Do not `require` a class file by hand.
|
|
18
|
+
- Declare one class, interface, trait, or enum per file, named after the file.
|
|
19
|
+
|
|
20
|
+
## Data shapes
|
|
21
|
+
|
|
22
|
+
- Use a backed `enum` for a closed set over class constants.
|
|
23
|
+
- Use `readonly` properties or a `readonly` class for value objects.
|
|
24
|
+
- Declare classes `final` unless they are designed for extension.
|
|
25
|
+
- Do not pass an associative array across a public boundary where a typed object fits.
|
|
26
|
+
|
|
27
|
+
## Errors
|
|
28
|
+
|
|
29
|
+
- Throw a domain exception for a failure. Do not return `false` or `null` to signal one.
|
|
30
|
+
- Never suppress an error with the `@` operator.
|
|
31
|
+
- Do not use `extract`, variable variables, or `eval`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Enforce Go testing package, table-driven cases, and test helper patterns for Go tests
|
|
3
|
+
paths:
|
|
4
|
+
- '**/*_test.go'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Go testing tooling
|
|
8
|
+
|
|
9
|
+
## Layer
|
|
10
|
+
|
|
11
|
+
- Load the `canon:test-craft` skill to pick the layer a test belongs at before choosing the tooling below, and report it rather than proceeding silently when the skill does not resolve.
|
|
12
|
+
|
|
13
|
+
## Framework
|
|
14
|
+
|
|
15
|
+
- Use the standard `testing` package. Do not add an assertion library for what `t.Errorf` and `t.Fatalf` already state.
|
|
16
|
+
- Place a test beside the code it tests, in a `_test.go` file of the same package.
|
|
17
|
+
- Use an external `<pkg>_test` package when a test should see only the exported API.
|
|
18
|
+
|
|
19
|
+
## Table-driven cases
|
|
20
|
+
|
|
21
|
+
- Write cases as a slice of structs run through `t.Run`, over one test function per case.
|
|
22
|
+
- Give every case a `name` field and pass it to `t.Run`.
|
|
23
|
+
|
|
24
|
+
## Helpers and setup
|
|
25
|
+
|
|
26
|
+
- Call `t.Helper()` first in every test helper.
|
|
27
|
+
- Use `t.Cleanup` over `defer` for teardown a helper registers.
|
|
28
|
+
- Use `t.TempDir` and `t.Setenv` over hand-rolled temp directories or environment stashes.
|
|
29
|
+
- Use `net/http/httptest` over a real listener.
|
|
30
|
+
|
|
31
|
+
## Concurrency
|
|
32
|
+
|
|
33
|
+
- Do not synchronize with `time.Sleep`. Wait on a channel, a `sync.WaitGroup`, or a deadline.
|
|
34
|
+
- Run tests with `-race`.
|
|
35
|
+
- Mark a test `t.Parallel()` only when it shares no mutable state with another test.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Enforce PHPUnit test classes, attributes, and strict assertions for PHP tests
|
|
3
|
+
paths:
|
|
4
|
+
- 'tests/**/*.php'
|
|
5
|
+
- '**/*Test.php'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# PHP testing tooling
|
|
9
|
+
|
|
10
|
+
## Layer
|
|
11
|
+
|
|
12
|
+
- Load the `canon:test-craft` skill to pick the layer a test belongs at before choosing the tooling below, and report it rather than proceeding silently when the skill does not resolve.
|
|
13
|
+
|
|
14
|
+
## Framework
|
|
15
|
+
|
|
16
|
+
- Use PHPUnit for all tests.
|
|
17
|
+
- Declare each test class `final` and extend `PHPUnit\Framework\TestCase`.
|
|
18
|
+
- Place tests under `tests/`, mirroring the `src/` namespace layout, in files named `*Test.php`.
|
|
19
|
+
|
|
20
|
+
## Attributes
|
|
21
|
+
|
|
22
|
+
- Use attributes (`#[Test]`, `#[DataProvider]`, `#[CoversClass]`) over docblock annotations.
|
|
23
|
+
- Use a static data provider method for table-driven cases over per-case test methods.
|
|
24
|
+
|
|
25
|
+
## Assertions and doubles
|
|
26
|
+
|
|
27
|
+
- Use `assertSame` over `assertEquals`.
|
|
28
|
+
- Use `expectException` over `try` and `catch` for expected exceptions.
|
|
29
|
+
- Use `createStub` where no interaction is asserted. Reserve `createMock` for asserting a call.
|
|
30
|
+
- Do not make real network calls. Use a fake or a stubbed client.
|
|
@@ -60,3 +60,4 @@ paths:
|
|
|
60
60
|
|
|
61
61
|
- Let content reflow at a viewport 320 CSS pixels wide with no loss of content or function and no two-dimensional scrolling. Only content whose meaning depends on both axes, such as a data table, a map, or a diagram, may scroll sideways (WCAG Reflow, 1.4.10, Level AA).
|
|
62
62
|
- Treat 320 as the criterion's own test condition rather than a device width. It is equivalent to a 1280 pixel window at 400 percent zoom, so a layout that breaks there breaks for a person who zooms.
|
|
63
|
+
- Hold the same 320 floor for the content region beside persistent chrome a person can resize. The criterion measures the viewport, so this extension is this rule's own rather than the criterion's: a panel widened to 480 in a 768 window leaves 288 for the content, under the floor at a window the criterion passes. `440-surface-capture` states where the frame for it gets taken.
|
|
@@ -28,6 +28,8 @@ paths:
|
|
|
28
28
|
- Capture the full page. Do not capture a component in isolation.
|
|
29
29
|
- Declare the widths a layout case captures. Always include 320, the reflow floor from the reflow criterion in `410-a11y`, then add each breakpoint bucket the project writes rules for, read from the `## Layout` section of `DESIGN.md`.
|
|
30
30
|
- Fall back to the 320 floor alone when the project has no `DESIGN.md` or its record carries no `## Layout` section. Do not impose another project's breakpoints.
|
|
31
|
+
- Add a layout case at each bound when a surface renders persistent chrome a person can resize, such as a draggable sidebar or a split pane, one with the chrome at its narrowest and one at its widest. Take both at the narrowest declared width where the chrome stays persistent rather than collapsing, since a frame where it has turned into an overlay shows none of the width it takes.
|
|
32
|
+
- Read those bounds from the code that clamps the chrome, since `## Layout` has no slot for them. Judge each frame by the content width left beside the chrome rather than by the window width, against the 320 floor `410-a11y` holds for that region.
|
|
31
33
|
- Apply the declaration to a case that covers layout only. A case driving a menu open or an answer chosen tests a state, so it takes one width rather than the full set.
|
|
32
34
|
- Read a capture at 320 as proof that a frame was taken there, not that the page reflows correctly. The criterion is stated in `410-a11y` and nothing here enforces it.
|
|
33
35
|
- Write the capture into a folder named `evidence`. The image is collected for the pull request comment when any segment of its path is literally `evidence` and its filename carries an image extension, so the folder name is fixed and its position in the tree is the project's own.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
extends = "node"
|
|
2
|
-
rules = ["200-react", "
|
|
2
|
+
rules = ["200-react", "250-tailwind", "300-testing-ts", "305-e2e-reliability", "306-test-scope", "310-zod", "350-security-web", "400-ui", "410-a11y", "420-forms", "430-ux-completeness", "440-surface-capture", "450-link-behavior", "460-design-taste", "465-interface-casing","470-motion"]
|
package/package.json
CHANGED
package/src/audits/catalog.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { SkillsAuditRefusal } from '@/claude/skills-audit'
|
|
|
2
2
|
import type { RankRefusal } from '@/claude/skills-rank'
|
|
3
3
|
import type { ReachRefusal } from '@/claude/skills-reach'
|
|
4
4
|
import type { ContextAuditRefusal } from '@/context/audit'
|
|
5
|
+
import type { SweepVerdict } from '@/context/classify/patterns'
|
|
5
6
|
import type { AuditRefusal } from '@/deps/audit'
|
|
6
7
|
import type { RestatedRefusal } from '@/gov/restated'
|
|
7
8
|
import type { LabelAuditRefusal } from '@/labels/audit'
|
|
@@ -199,6 +200,45 @@ function architectureCounts(
|
|
|
199
200
|
}
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The retained key for each verdict the sweep can return. A record rather than
|
|
205
|
+
* a list, so the compiler fails here when the classifier gains a verdict,
|
|
206
|
+
* instead of that verdict's sections dropping out of the tally.
|
|
207
|
+
*/
|
|
208
|
+
const PLACEMENT_KEYS: Record<SweepVerdict, string> = {
|
|
209
|
+
KEEP: 'keep',
|
|
210
|
+
REWRITE: 'rewrite',
|
|
211
|
+
MOVE: 'move',
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPlacementVerdict(value: unknown): value is SweepVerdict {
|
|
215
|
+
return typeof value === 'string' && Object.hasOwn(PLACEMENT_KEYS, value)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Tallies the sweep's findings by verdict, with a zero for a verdict nothing
|
|
220
|
+
* returned, so a baseline reads the same three keys on every run.
|
|
221
|
+
*
|
|
222
|
+
* `KEEP` is retained beside the two that ask for a change. It moves when a
|
|
223
|
+
* section is added or split, which is what tells a falling `rewrite` count
|
|
224
|
+
* from a corpus that shrank.
|
|
225
|
+
*/
|
|
226
|
+
function placementCounts(record: unknown): Record<string, number> | undefined {
|
|
227
|
+
const findings = asObject(record)?.findings
|
|
228
|
+
if (!Array.isArray(findings)) return undefined
|
|
229
|
+
|
|
230
|
+
const counts: Record<string, number> = Object.fromEntries(
|
|
231
|
+
Object.values(PLACEMENT_KEYS).map((key) => [key, 0]),
|
|
232
|
+
)
|
|
233
|
+
for (const finding of findings) {
|
|
234
|
+
const verdict = asObject(finding)?.verdict
|
|
235
|
+
if (!isPlacementVerdict(verdict)) return undefined
|
|
236
|
+
counts[PLACEMENT_KEYS[verdict]] += 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return counts
|
|
240
|
+
}
|
|
241
|
+
|
|
202
242
|
function contextCounts(record: unknown): Record<string, number> | undefined {
|
|
203
243
|
const root = asObject(record)
|
|
204
244
|
if (root === undefined) return undefined
|
|
@@ -502,6 +542,18 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
502
542
|
absentReasons: ['no-folders'] satisfies ContextAuditRefusal[],
|
|
503
543
|
counts: contextCounts,
|
|
504
544
|
},
|
|
545
|
+
{
|
|
546
|
+
id: 'placement',
|
|
547
|
+
label: 'Context placement',
|
|
548
|
+
// Pinned to the regex layer rather than the resolved classifier setting.
|
|
549
|
+
// A count that moves with whichever model a machine runs is not
|
|
550
|
+
// comparable across clones, and the model layer turns a sub-second read
|
|
551
|
+
// into a multi-minute one. `document-health` is where the model reads.
|
|
552
|
+
argv: ['context', 'classify', 'sweep', '--backend', 'off', '--json'],
|
|
553
|
+
gatingExits: [],
|
|
554
|
+
corpus: 'tracked',
|
|
555
|
+
counts: placementCounts,
|
|
556
|
+
},
|
|
505
557
|
{
|
|
506
558
|
id: 'markdown',
|
|
507
559
|
label: 'Markdown corpus',
|
package/src/commands/context.ts
CHANGED
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
missingSections,
|
|
15
15
|
PROVENANCE_FOLDER,
|
|
16
16
|
REQUIRED_SECTIONS,
|
|
17
|
+
REQUIRED_SECTIONS_BY_FOLDER,
|
|
18
|
+
requiredSections,
|
|
17
19
|
type SectionFinding,
|
|
18
20
|
} from '@/context/audit'
|
|
19
21
|
import {
|
|
@@ -129,8 +131,9 @@ export function register(program: Command): void {
|
|
|
129
131
|
'it holds more decisions than the cap. A record stating neither is',
|
|
130
132
|
'reported and never gated. --gate widens the gate to the other two',
|
|
131
133
|
'findings that are facts rather than judgments: a missing required',
|
|
132
|
-
'section and index',
|
|
133
|
-
'
|
|
134
|
+
'section and index drift. A context entry requires Overview and',
|
|
135
|
+
'Layout, and a wireframe requires Regions, States, Copy, and Not on',
|
|
136
|
+
'this surface. Entry length, reference form, table, provenance, narration,',
|
|
134
137
|
'and the record claim classification are judgments under both.',
|
|
135
138
|
'',
|
|
136
139
|
'Depth and bullet weight are stated over every markdown file rather',
|
|
@@ -740,6 +743,10 @@ async function runAudit(
|
|
|
740
743
|
renderWidth: RENDER_WIDTH,
|
|
741
744
|
provenanceFolder: PROVENANCE_FOLDER,
|
|
742
745
|
requiredSections: REQUIRED_SECTIONS,
|
|
746
|
+
// Keyed on the audited folder name. The flat key above stays for a
|
|
747
|
+
// caller that read it before wireframes owed a section, since turning
|
|
748
|
+
// it into this map would break that caller without an error.
|
|
749
|
+
requiredSectionsByFolder: REQUIRED_SECTIONS_BY_FOLDER,
|
|
743
750
|
// Three states rather than two, so a record showing no finding says
|
|
744
751
|
// which terms were looked for. The key is absent when the run never
|
|
745
752
|
// scanned, which is `--citations-only`, and null when it scanned and
|
|
@@ -946,7 +953,8 @@ function reportReferenceForm(
|
|
|
946
953
|
|
|
947
954
|
/**
|
|
948
955
|
* Names the path each finding belongs to, which is an entry in the folder named
|
|
949
|
-
* under `.claude
|
|
956
|
+
* under `.claude/`, the folder itself in a domain split across one, and the
|
|
957
|
+
* file in any wireframe folder. States
|
|
950
958
|
* the reach on every run for the reason the provenance report does.
|
|
951
959
|
*
|
|
952
960
|
* This prints ahead of the four readability measures because a missing section
|
|
@@ -959,19 +967,31 @@ function reportSections(
|
|
|
959
967
|
): void {
|
|
960
968
|
logStep('Sections')
|
|
961
969
|
|
|
962
|
-
const
|
|
963
|
-
|
|
970
|
+
const names = [
|
|
971
|
+
...new Set(
|
|
972
|
+
folders
|
|
973
|
+
.filter((folder) => requiredSections(folder).length > 0)
|
|
974
|
+
.map((folder) => folder.name),
|
|
975
|
+
),
|
|
976
|
+
]
|
|
977
|
+
if (names.length === 0) {
|
|
964
978
|
logInfo(
|
|
965
|
-
`Out of scope. The
|
|
979
|
+
`Out of scope. The lists are stated in the standards governing ${Object.keys(
|
|
980
|
+
REQUIRED_SECTIONS_BY_FOLDER,
|
|
981
|
+
)
|
|
982
|
+
.map((name) => `canon/${name}/`)
|
|
983
|
+
.join(' and ')}, and no audited folder is one of them.`,
|
|
966
984
|
)
|
|
967
985
|
return
|
|
968
986
|
}
|
|
969
987
|
|
|
988
|
+
for (const name of names) {
|
|
989
|
+
logInfo(
|
|
990
|
+
`canon/${name}/ requires ${REQUIRED_SECTIONS_BY_FOLDER[name].join(', ')}.`,
|
|
991
|
+
)
|
|
992
|
+
}
|
|
970
993
|
logInfo(
|
|
971
|
-
|
|
972
|
-
)
|
|
973
|
-
logInfo(
|
|
974
|
-
'A heading at any level counts. Each entry answers for itself, except in a domain split across a folder, where a sibling answers for the rest.',
|
|
994
|
+
'A heading at any level counts. Each entry answers for itself, except in a context domain split across a folder, where a sibling answers for the rest. A wireframe answers for itself in every folder.',
|
|
975
995
|
)
|
|
976
996
|
|
|
977
997
|
if (missing.length === 0) {
|
package/src/commands/records.ts
CHANGED
|
@@ -330,6 +330,12 @@ function backupHelp(verb: 'push' | 'pull'): string {
|
|
|
330
330
|
'It commits nothing outside the folders above and leaves the project working',
|
|
331
331
|
'tree untouched. The push runs even when this call committed nothing, since a',
|
|
332
332
|
'previous run can have committed and then failed to reach the network.',
|
|
333
|
+
'',
|
|
334
|
+
'Before staging, it refuses the whole push as unsafe-payload when any new or',
|
|
335
|
+
'changed file is over 25 MB or carries a credential canon secrets scan would',
|
|
336
|
+
'report, naming each path. Nothing is staged or committed when it refuses.',
|
|
337
|
+
'A push the remote rejects undoes the commit this call made, so the records',
|
|
338
|
+
'stay on disk and no later run sends that commit again.',
|
|
333
339
|
]
|
|
334
340
|
: [
|
|
335
341
|
'It refuses rather than discarding local records that never reached the remote,',
|
|
@@ -569,6 +575,12 @@ async function runPush(opts: BackupCommandOptions): Promise<number> {
|
|
|
569
575
|
if (outcome.firstSeen.length > 0) {
|
|
570
576
|
logWarn(`first seen: ${outcome.firstSeen.join(', ')}`)
|
|
571
577
|
}
|
|
578
|
+
for (const path of outcome.added) logInfo(`added: ${path}`)
|
|
579
|
+
if (outcome.dropped.length > 0) {
|
|
580
|
+
logWarn(
|
|
581
|
+
`excluded but still tracked, removed from the history going forward: ${outcome.dropped.join(', ')}`,
|
|
582
|
+
)
|
|
583
|
+
}
|
|
572
584
|
logStep(outcome.pushed ? 'Pushed' : 'Nothing to push')
|
|
573
585
|
logInfo(
|
|
574
586
|
outcome.commit
|
|
@@ -609,7 +621,11 @@ async function runPull(opts: BackupCommandOptions): Promise<number> {
|
|
|
609
621
|
*/
|
|
610
622
|
function reportRefusal(
|
|
611
623
|
banner: string,
|
|
612
|
-
outcome: {
|
|
624
|
+
outcome: {
|
|
625
|
+
readonly reason: string
|
|
626
|
+
readonly message: string
|
|
627
|
+
readonly blocked?: readonly unknown[]
|
|
628
|
+
},
|
|
613
629
|
emitJson: boolean,
|
|
614
630
|
): number {
|
|
615
631
|
if (emitJson) {
|
|
@@ -619,6 +635,7 @@ function reportRefusal(
|
|
|
619
635
|
ok: false,
|
|
620
636
|
reason: outcome.reason,
|
|
621
637
|
message: outcome.message,
|
|
638
|
+
...(outcome.blocked ? { blocked: outcome.blocked } : {}),
|
|
622
639
|
})}\n`,
|
|
623
640
|
)
|
|
624
641
|
return 1
|
package/src/commands/teach.ts
CHANGED
|
@@ -797,6 +797,7 @@ function reportNav(
|
|
|
797
797
|
root: outcome.root,
|
|
798
798
|
contents: outcome.contents,
|
|
799
799
|
lessons: outcome.lessons,
|
|
800
|
+
reference: outcome.reference,
|
|
800
801
|
skipped: outcome.skipped,
|
|
801
802
|
})}\n`,
|
|
802
803
|
)
|
|
@@ -810,9 +811,11 @@ function reportNav(
|
|
|
810
811
|
for (const path of outcome.contents) logInfo(path)
|
|
811
812
|
logStep('Lessons rewritten')
|
|
812
813
|
logInfo(String(outcome.lessons))
|
|
814
|
+
logStep('Reference pages rendered')
|
|
815
|
+
logInfo(String(outcome.reference))
|
|
813
816
|
|
|
814
817
|
if (outcome.skipped.length > 0) {
|
|
815
|
-
logStep('Refused, missing a chrome marker')
|
|
818
|
+
logStep('Refused, missing a chrome marker or the markdown renderer')
|
|
816
819
|
for (const skip of outcome.skipped) {
|
|
817
820
|
logWarn(`${skip.file}: no ${skip.missing}`)
|
|
818
821
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { readReference, resolveReference } from '@/tooling/read'
|
|
21
21
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
22
22
|
import { recordToolingChain } from '@/tooling/stamp'
|
|
23
|
+
import { subfolderPath } from '@/tooling/subfolder'
|
|
23
24
|
import {
|
|
24
25
|
intro,
|
|
25
26
|
isNonInteractive,
|
|
@@ -93,6 +94,11 @@ export function register(program: Command): void {
|
|
|
93
94
|
' canon tooling sync base',
|
|
94
95
|
' canon tooling sync base --check',
|
|
95
96
|
' CANON_NON_INTERACTIVE=1 canon tooling sync base --write',
|
|
97
|
+
' canon tooling sync vite-react web --skip base',
|
|
98
|
+
'',
|
|
99
|
+
'A target below its git root syncs as a subfolder. It withholds',
|
|
100
|
+
'.github/, which GitHub reads only at the root, and writes a nested',
|
|
101
|
+
'cspell.json registering the stack word lists.',
|
|
96
102
|
'',
|
|
97
103
|
'To gate CI on tooling drift, run headlessly with neither flag. That',
|
|
98
104
|
'exits 1 when a file would be replaced and 0 when none would, which is',
|
|
@@ -358,7 +364,7 @@ async function runSync(
|
|
|
358
364
|
|
|
359
365
|
const result = scan(prepared.chain, prepared.target)
|
|
360
366
|
|
|
361
|
-
report(result)
|
|
367
|
+
report(result, prepared.target)
|
|
362
368
|
|
|
363
369
|
if (opts.diff !== undefined) {
|
|
364
370
|
if (opts.diff.json) {
|
|
@@ -568,7 +574,7 @@ async function promptForStack(): Promise<string | undefined> {
|
|
|
568
574
|
})
|
|
569
575
|
}
|
|
570
576
|
|
|
571
|
-
function report(result: ScanResult): void {
|
|
577
|
+
function report(result: ScanResult, target: string): void {
|
|
572
578
|
logStep('Scanning configs')
|
|
573
579
|
for (const entry of result.configs) {
|
|
574
580
|
if (entry.state === 'matching') logInfo(entry.rel)
|
|
@@ -580,6 +586,8 @@ function report(result: ScanResult): void {
|
|
|
580
586
|
if (entry.state === 'new') logAdd(entry.rel)
|
|
581
587
|
}
|
|
582
588
|
|
|
589
|
+
if (result.withheld.length > 0) reportWithheld(result, target)
|
|
590
|
+
|
|
583
591
|
logStep('Scanning seeds')
|
|
584
592
|
for (const entry of result.seeds) {
|
|
585
593
|
if (entry.state === 'present') logInfo(entry.rel)
|
|
@@ -591,6 +599,7 @@ function report(result: ScanResult): void {
|
|
|
591
599
|
if (result.hasPackageJson) {
|
|
592
600
|
reportPackage(result)
|
|
593
601
|
} else {
|
|
602
|
+
logStep('Scanning package.json')
|
|
594
603
|
logWarn(
|
|
595
604
|
"Skipped scripts and deps: no package.json found (run 'bun init' to enable)",
|
|
596
605
|
)
|
|
@@ -605,6 +614,20 @@ function report(result: ScanResult): void {
|
|
|
605
614
|
}
|
|
606
615
|
}
|
|
607
616
|
|
|
617
|
+
function reportWithheld(result: ScanResult, target: string): void {
|
|
618
|
+
logStep('Withheld from a subfolder')
|
|
619
|
+
for (const entry of result.withheld) logWarn(entry.rel)
|
|
620
|
+
for (const entry of result.withheld) {
|
|
621
|
+
if (entry.present) {
|
|
622
|
+
logWarn(`${entry.rel} is already here, and GitHub never reads this copy`)
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
logInfo(
|
|
626
|
+
'GitHub reads workflows only at the repository root. Add a root job that runs this folder with:',
|
|
627
|
+
)
|
|
628
|
+
logInfo(`working-directory: ${subfolderPath(target) ?? '.'}`)
|
|
629
|
+
}
|
|
630
|
+
|
|
608
631
|
function reportPackage(result: ScanResult): void {
|
|
609
632
|
if (result.scripts.length > 0) {
|
|
610
633
|
logStep('Scanning scripts')
|
package/src/context/audit.ts
CHANGED
|
@@ -45,19 +45,35 @@ export const CATALOG_ROW_CHECKPOINT = 6
|
|
|
45
45
|
const CATALOG_NAMED_RATIO = 0.6
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* Required sections per audited folder name, each in the order its standard
|
|
49
|
+
* states them.
|
|
49
50
|
*
|
|
50
|
-
* The
|
|
51
|
-
* checkpoints above quote their numbers. A parser over
|
|
51
|
+
* The lists are held here rather than read out of the standards, the way the
|
|
52
|
+
* four checkpoints above quote their numbers. A parser over a standard's prose
|
|
52
53
|
* would decide which sections are required from the wording around them, so it
|
|
53
54
|
* fails on a rewrite of that wording rather than on a defect in an entry.
|
|
54
55
|
*
|
|
55
56
|
* These names do not generalize the way a length threshold does, which is why
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* each list is keyed on the folder whose standard states it. `context.md`
|
|
58
|
+
* requires an overview and a layout. `wireframes.md` asks four questions of a
|
|
59
|
+
* surface and answers each in a section, so those four are required and
|
|
60
|
+
* `## Behavior` is not, since a static surface has no interaction to state. A
|
|
61
|
+
* diagram entry declares a heading per kind and its standard requires none.
|
|
59
62
|
*/
|
|
60
|
-
export const
|
|
63
|
+
export const REQUIRED_SECTIONS_BY_FOLDER: Readonly<
|
|
64
|
+
Record<string, readonly string[]>
|
|
65
|
+
> = {
|
|
66
|
+
context: ['Overview', 'Layout'],
|
|
67
|
+
wireframes: ['Regions', 'States', 'Copy', 'Not on this surface'],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The context standard's list, kept as its own export because the audit record
|
|
72
|
+
* has published it flat under `checkpoints.requiredSections` since before any
|
|
73
|
+
* other folder required a section.
|
|
74
|
+
*/
|
|
75
|
+
export const REQUIRED_SECTIONS: readonly string[] =
|
|
76
|
+
REQUIRED_SECTIONS_BY_FOLDER.context
|
|
61
77
|
|
|
62
78
|
const HEADING_TEXT = /^#{1,6}\s+(.+?)\s*$/
|
|
63
79
|
const TABLE_ROW = /^\s*\|/
|
|
@@ -226,7 +242,7 @@ export interface EntryReport {
|
|
|
226
242
|
readonly bareReferences: readonly BareReferenceFinding[]
|
|
227
243
|
/**
|
|
228
244
|
* Required sections this entry declares, in the standard's order, and empty
|
|
229
|
-
* outside
|
|
245
|
+
* outside a folder whose standard names them. What the folder is short of
|
|
230
246
|
* is `missingSections`, since one entry answers for its siblings.
|
|
231
247
|
*/
|
|
232
248
|
readonly sections: readonly string[]
|
|
@@ -550,17 +566,20 @@ function narration(
|
|
|
550
566
|
* Fenced blocks are skipped for the reason the scans above skip them. A
|
|
551
567
|
* standard quoted inside an example declares nothing about the entry quoting it.
|
|
552
568
|
*/
|
|
553
|
-
function declaredSections(
|
|
569
|
+
function declaredSections(
|
|
570
|
+
lines: readonly BodyLine[],
|
|
571
|
+
required: readonly string[],
|
|
572
|
+
): string[] {
|
|
554
573
|
const found = new Set<string>()
|
|
555
574
|
|
|
556
575
|
for (const line of lines) {
|
|
557
576
|
if (line.fenced) continue
|
|
558
577
|
|
|
559
578
|
const match = line.text.match(HEADING_TEXT)
|
|
560
|
-
if (match &&
|
|
579
|
+
if (match && required.includes(match[1])) found.add(match[1])
|
|
561
580
|
}
|
|
562
581
|
|
|
563
|
-
return
|
|
582
|
+
return required.filter((section) => found.has(section))
|
|
564
583
|
}
|
|
565
584
|
|
|
566
585
|
/**
|
|
@@ -611,7 +630,9 @@ function bareReferences(
|
|
|
611
630
|
* The caller passes jurisdiction rather than deriving it from `rel`, because a
|
|
612
631
|
* path prefix hardcodes what `--folder` exists to override and misses a domain
|
|
613
632
|
* split into `context/<sub-area>/`. Sibling names arrive the same way and for
|
|
614
|
-
* the same reason, since the folder an entry sits in is what holds them.
|
|
633
|
+
* the same reason, since the folder an entry sits in is what holds them. The
|
|
634
|
+
* required sections arrive apart from jurisdiction, since a wireframe owes
|
|
635
|
+
* sections under a standard that states no provenance rule.
|
|
615
636
|
*/
|
|
616
637
|
export function measureEntry(
|
|
617
638
|
rel: string,
|
|
@@ -619,6 +640,7 @@ export function measureEntry(
|
|
|
619
640
|
governsContent = true,
|
|
620
641
|
terms?: NarrationTerms,
|
|
621
642
|
siblings: readonly string[] = [],
|
|
643
|
+
required: readonly string[] = governsContent ? REQUIRED_SECTIONS : [],
|
|
622
644
|
): EntryReport {
|
|
623
645
|
const lines = bodyLines(source)
|
|
624
646
|
|
|
@@ -632,7 +654,7 @@ export function measureEntry(
|
|
|
632
654
|
provenance: governsContent ? provenance(lines) : [],
|
|
633
655
|
narration: governsContent && terms ? narration(lines, terms) : [],
|
|
634
656
|
bareReferences: bareReferences(lines, siblings),
|
|
635
|
-
sections:
|
|
657
|
+
sections: declaredSections(lines, required),
|
|
636
658
|
stub: isStubSeed(source),
|
|
637
659
|
governed: governsContent,
|
|
638
660
|
}
|
|
@@ -713,6 +735,7 @@ export async function measureFolders(
|
|
|
713
735
|
governsContent(folder),
|
|
714
736
|
terms,
|
|
715
737
|
names.filter((name) => name !== self),
|
|
738
|
+
requiredSections(folder),
|
|
716
739
|
),
|
|
717
740
|
)
|
|
718
741
|
}
|
|
@@ -726,6 +749,13 @@ export function governsContent(folder: AuditedFolder): boolean {
|
|
|
726
749
|
return folder.name === PROVENANCE_FOLDER
|
|
727
750
|
}
|
|
728
751
|
|
|
752
|
+
/** Names the sections the folder's standard requires, empty where it states none. */
|
|
753
|
+
export function requiredSections(folder: AuditedFolder): readonly string[] {
|
|
754
|
+
return Object.hasOwn(REQUIRED_SECTIONS_BY_FOLDER, folder.name)
|
|
755
|
+
? REQUIRED_SECTIONS_BY_FOLDER[folder.name]
|
|
756
|
+
: []
|
|
757
|
+
}
|
|
758
|
+
|
|
729
759
|
/**
|
|
730
760
|
* Reports whether a bare sibling name here is a reference by construction.
|
|
731
761
|
*
|
|
@@ -765,6 +795,11 @@ export function matchesSiblings(folder: AuditedFolder): boolean {
|
|
|
765
795
|
* The standard sanctions omitting `## Layout` from a domain owning no paths,
|
|
766
796
|
* which no measure can tell from an entry that forgot it. An entry of that
|
|
767
797
|
* shape therefore reports, which is a reason this is printed and never gated on.
|
|
798
|
+
*
|
|
799
|
+
* A wireframe folder answers per file whether nested or not, since its
|
|
800
|
+
* standard keeps one surface per file. A subfolder of wireframes groups
|
|
801
|
+
* surfaces the way the named context folder groups domains, so a conforming
|
|
802
|
+
* sibling there says nothing about the surface beside it.
|
|
768
803
|
*/
|
|
769
804
|
export function missingSections(
|
|
770
805
|
root: string,
|
|
@@ -774,11 +809,12 @@ export function missingSections(
|
|
|
774
809
|
const byRel = new Map(entries.map((entry) => [entry.rel, entry]))
|
|
775
810
|
const findings: SectionFinding[] = []
|
|
776
811
|
|
|
777
|
-
const shortOf = (declared: readonly string[]): string[] =>
|
|
778
|
-
REQUIRED_SECTIONS.filter((name) => !declared.includes(name))
|
|
779
|
-
|
|
780
812
|
for (const folder of folders) {
|
|
781
|
-
|
|
813
|
+
const required = requiredSections(folder)
|
|
814
|
+
if (required.length === 0 || folder.entries.length === 0) continue
|
|
815
|
+
|
|
816
|
+
const shortOf = (declared: readonly string[]): string[] =>
|
|
817
|
+
required.filter((name) => !declared.includes(name))
|
|
782
818
|
|
|
783
819
|
// A stub owes no sections, so it is dropped before either branch rather
|
|
784
820
|
// than inside them. Leaving one in the split-folder aggregate would let a
|
|
@@ -790,7 +826,7 @@ export function missingSections(
|
|
|
790
826
|
|
|
791
827
|
if (reports.length === 0) continue
|
|
792
828
|
|
|
793
|
-
if (folder.nested) {
|
|
829
|
+
if (folder.nested && governsContent(folder)) {
|
|
794
830
|
const missing = shortOf(reports.flatMap((entry) => entry.sections))
|
|
795
831
|
if (missing.length > 0) findings.push({ rel: folder.rel, missing })
|
|
796
832
|
continue
|