@erclx/canon 4.113.0 → 4.114.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/target-setup/SKILL.md +2 -0
- package/claude/skills/target-setup/references/detect.md +2 -2
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/commands.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 +6 -4
- package/docs/target-projects.md +9 -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/commands/records.ts +18 -1
- package/src/commands/tooling.ts +25 -2
- 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 +75 -25
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/tooling/base/configs/.husky/post-merge +5 -1
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/web/configs/scripts/verify.sh +32 -15
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/design/components.ts
CHANGED
|
@@ -594,6 +594,23 @@ html.sb-shut .sb { flex-basis: 0; width: 0; overflow: hidden; }
|
|
|
594
594
|
.sb-ws > summary:hover, .sb-ws[open] > summary { background: var(--color-chrome); color: var(--color-text); }
|
|
595
595
|
.sb-ws .car { color: var(--color-muted); display: inline-flex; align-items: center; }
|
|
596
596
|
|
|
597
|
+
/* The top band is as tall as the masthead and a mission title can run to a
|
|
598
|
+
sentence, so the name stops at two lines rather than overflowing the rule.
|
|
599
|
+
Nav carries the whole title in the element's \`title\` attribute. The cap
|
|
600
|
+
trim the crumb labels share is dropped here, since it ends the box at the
|
|
601
|
+
last baseline and the clip would cut every descender on the second line. */
|
|
602
|
+
.sb-ws { min-width: 0; }
|
|
603
|
+
.sb-ws .ws-name {
|
|
604
|
+
display: -webkit-box;
|
|
605
|
+
-webkit-box-orient: vertical;
|
|
606
|
+
-webkit-line-clamp: 2;
|
|
607
|
+
line-clamp: 2;
|
|
608
|
+
overflow: hidden;
|
|
609
|
+
min-width: 0;
|
|
610
|
+
line-height: 1.2;
|
|
611
|
+
text-box: normal;
|
|
612
|
+
}
|
|
613
|
+
|
|
597
614
|
.sb-wl {
|
|
598
615
|
position: absolute;
|
|
599
616
|
top: calc(100% + 0.35rem);
|
package/src/design/tokens.ts
CHANGED
|
@@ -373,7 +373,7 @@ export const TOKENS: DesignTokens = {
|
|
|
373
373
|
],
|
|
374
374
|
|
|
375
375
|
layout:
|
|
376
|
-
'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor
|
|
376
|
+
'Capture widths are 320, 768, 1280 and 1536 pixels, declared once in `tooling/web/configs/e2e/screenshot.ts`, with 320 and 1280 committed as evidence. 320 is the reflow floor. Every range the width queries in the generated base and teach stylesheets lay out holds at least one of these widths, so a capture photographs every layout state those stylesheets have, and a design test fails when a query moves or is added so that it opens a range none of them reaches. The landing page under `web/` sets its own `max-width` media queries between 430 and 1100 pixels per component rather than from a shared scale.',
|
|
377
377
|
|
|
378
378
|
motion:
|
|
379
379
|
'Motion is not used. No transition, animation, or keyframe declaration appears on any rendered surface, and the capture pipeline screenshots a static frame.',
|
package/src/gate/measures.ts
CHANGED
|
@@ -103,11 +103,22 @@ const output = (text: string): Emission => ({ kind: 'output', text })
|
|
|
103
103
|
* `snippets` as a folder-whole entry of its own and the rule reaches every base
|
|
104
104
|
* consumer through that instead.
|
|
105
105
|
*
|
|
106
|
-
*
|
|
106
|
+
* `130-go`, `140-php`, `335-testing-go`, and `336-testing-php` are opt-in by
|
|
107
|
+
* `--add` until a Go or PHP stack ships, which `target-setup` reaches by
|
|
108
|
+
* matching a detected language against each rule's description.
|
|
109
|
+
*
|
|
110
|
+
* All are recorded here rather than in a config file: the list is what a
|
|
107
111
|
* reader compares a new arrival against, and a config file would absorb the
|
|
108
112
|
* arrival silently.
|
|
109
113
|
*/
|
|
110
|
-
export const GOV_EXPECTED_UNREFERENCED = [
|
|
114
|
+
export const GOV_EXPECTED_UNREFERENCED = [
|
|
115
|
+
'130-go',
|
|
116
|
+
'140-php',
|
|
117
|
+
'260-shadcn',
|
|
118
|
+
'320-tanstack-query',
|
|
119
|
+
'335-testing-go',
|
|
120
|
+
'336-testing-php',
|
|
121
|
+
]
|
|
111
122
|
|
|
112
123
|
/**
|
|
113
124
|
* Scenarios declaring no expectation, taken from `canon sandbox coverage`
|
package/src/records/backup.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
1
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
|
-
import { RECORD_ROOTS, recordRoot } from '@/record-root'
|
|
5
|
+
import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
|
|
6
|
+
import { type BlockedPath, guardPayload } from '@/records/push-guard'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The legacy `.claude`-root allowlist, read only when a project has not moved
|
|
@@ -81,6 +82,11 @@ const RECORDS_GIT_NAME = '.records.git'
|
|
|
81
82
|
* every entry already keeps a pull's `README.md` out, so this is the one name
|
|
82
83
|
* that still has to be said: without it a listing would stage the history
|
|
83
84
|
* into itself.
|
|
85
|
+
*
|
|
86
|
+
* The list bounds the records index as well as the disk. `scopedFolders`
|
|
87
|
+
* filters the names the index tracks through `isExcluded`, so a name an older
|
|
88
|
+
* binary committed is removed from the history once rather than re-staged on
|
|
89
|
+
* every push by the `-f` that carries the payload.
|
|
84
90
|
*/
|
|
85
91
|
export const EXCLUDED_ENTRIES = [
|
|
86
92
|
'tmp',
|
|
@@ -88,6 +94,20 @@ export const EXCLUDED_ENTRIES = [
|
|
|
88
94
|
RECORDS_GIT_NAME,
|
|
89
95
|
] as const
|
|
90
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether a top-level name stays out of every push, at either root.
|
|
99
|
+
*
|
|
100
|
+
* `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
|
|
101
|
+
* the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
|
|
102
|
+
* spelling only.
|
|
103
|
+
*/
|
|
104
|
+
function isExcluded(name: string): boolean {
|
|
105
|
+
return (
|
|
106
|
+
name === SCRATCH ||
|
|
107
|
+
EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number])
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/**
|
|
92
112
|
* The record folders sitting at `dir` right now, at either root spelling.
|
|
93
113
|
*
|
|
@@ -112,10 +132,7 @@ function foldersAt(dir: string): string[] {
|
|
|
112
132
|
return entries
|
|
113
133
|
.filter((entry) => entry.isDirectory())
|
|
114
134
|
.map((entry) => entry.name)
|
|
115
|
-
.filter(
|
|
116
|
-
(name) =>
|
|
117
|
-
!EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number]),
|
|
118
|
-
)
|
|
135
|
+
.filter((name) => !isExcluded(name))
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
/**
|
|
@@ -197,6 +214,7 @@ export const BACKUP_REFUSALS = [
|
|
|
197
214
|
'no-remote-records',
|
|
198
215
|
'local-changes',
|
|
199
216
|
'local-ahead',
|
|
217
|
+
'unsafe-payload',
|
|
200
218
|
'git-failed',
|
|
201
219
|
] as const
|
|
202
220
|
|
|
@@ -206,6 +224,8 @@ export interface BackupRefused {
|
|
|
206
224
|
readonly ok: false
|
|
207
225
|
readonly reason: BackupRefusal
|
|
208
226
|
readonly message: string
|
|
227
|
+
/** Every pending path the payload guard refused, carried on `unsafe-payload` only. */
|
|
228
|
+
readonly blocked?: readonly BlockedPath[]
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
export interface PushReport {
|
|
@@ -214,6 +234,10 @@ export interface PushReport {
|
|
|
214
234
|
readonly folders: readonly string[]
|
|
215
235
|
/** A folder in scope this push found on disk but the records index had never tracked. */
|
|
216
236
|
readonly firstSeen: readonly string[]
|
|
237
|
+
/** Every file this push stages that the records history has never carried. */
|
|
238
|
+
readonly added: readonly string[]
|
|
239
|
+
/** An excluded name the records index still tracked, removed from the history going forward. */
|
|
240
|
+
readonly dropped: readonly string[]
|
|
217
241
|
readonly changed: number
|
|
218
242
|
readonly commit?: string
|
|
219
243
|
readonly pushed: boolean
|
|
@@ -450,10 +474,14 @@ function refuseSplitRoots(root: string): BackupRefused | undefined {
|
|
|
450
474
|
)
|
|
451
475
|
}
|
|
452
476
|
|
|
453
|
-
/**
|
|
477
|
+
/**
|
|
478
|
+
* A pathspec-safe subset, which of it never entered the records index before,
|
|
479
|
+
* and which excluded names the index still tracks.
|
|
480
|
+
*/
|
|
454
481
|
interface FolderScope {
|
|
455
482
|
readonly scope: readonly string[]
|
|
456
483
|
readonly firstSeen: readonly string[]
|
|
484
|
+
readonly dropped: readonly string[]
|
|
457
485
|
}
|
|
458
486
|
|
|
459
487
|
/**
|
|
@@ -476,12 +504,19 @@ interface FolderScope {
|
|
|
476
504
|
* tracked, which is the read a caller reports rather than acts on: a folder
|
|
477
505
|
* `.canon` picked up that should have been excluded is visible in the push
|
|
478
506
|
* report instead of entering the payload silently.
|
|
507
|
+
*
|
|
508
|
+
* The index half passes through `isExcluded` before the union, since the index
|
|
509
|
+
* is the one side an excluded name can still reach the pathspec through. A
|
|
510
|
+
* name it drops is returned as `dropped` rather than silently forgotten, so the
|
|
511
|
+
* push can remove it from the history once and say so.
|
|
479
512
|
*/
|
|
480
513
|
async function scopedFolders(root: string): Promise<FolderScope> {
|
|
481
514
|
const tracked = await records(root, ['ls-files'])
|
|
482
|
-
const
|
|
515
|
+
const everIndexed = new Set(
|
|
483
516
|
tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
|
|
484
517
|
)
|
|
518
|
+
const indexed = new Set([...everIndexed].filter((name) => !isExcluded(name)))
|
|
519
|
+
const dropped = [...everIndexed].filter(isExcluded).sort()
|
|
485
520
|
|
|
486
521
|
const present = new Set(foldersAt(workTree(root)))
|
|
487
522
|
const retired = RETIRED_FOLDERS.filter(
|
|
@@ -492,7 +527,7 @@ async function scopedFolders(root: string): Promise<FolderScope> {
|
|
|
492
527
|
(folder) => present.has(folder) && !indexed.has(folder),
|
|
493
528
|
)
|
|
494
529
|
|
|
495
|
-
return { scope, firstSeen }
|
|
530
|
+
return { scope, firstSeen, dropped }
|
|
496
531
|
}
|
|
497
532
|
|
|
498
533
|
function topSegment(path: string): string {
|
|
@@ -531,12 +566,105 @@ function countLines(text: string): number {
|
|
|
531
566
|
return text.split('\n').filter(Boolean).length
|
|
532
567
|
}
|
|
533
568
|
|
|
569
|
+
function splitNul(text: string): string[] {
|
|
570
|
+
return text.split('\0').filter(Boolean)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The files `add -A -f` is about to stage from `scope`, split into those the
|
|
575
|
+
* history has never carried and every pending one.
|
|
576
|
+
*
|
|
577
|
+
* `ls-files -o` without `--exclude-standard` lists ignored files too, which is
|
|
578
|
+
* what `-f` stages. `-m` also lists a deleted file and `-o` lists an embedded
|
|
579
|
+
* repository as a `dir/` entry, so both are filtered down to regular files,
|
|
580
|
+
* which is all a payload check has bytes to read.
|
|
581
|
+
*/
|
|
582
|
+
async function pendingFiles(
|
|
583
|
+
root: string,
|
|
584
|
+
scope: readonly string[],
|
|
585
|
+
): Promise<{ added: string[]; pending: string[] } | BackupRefused> {
|
|
586
|
+
if (scope.length === 0) return { added: [], pending: [] }
|
|
587
|
+
|
|
588
|
+
const [others, modified] = await Promise.all([
|
|
589
|
+
records(root, ['ls-files', '-z', '-o', '--', ...scope]),
|
|
590
|
+
records(root, ['ls-files', '-z', '-m', '--', ...scope]),
|
|
591
|
+
])
|
|
592
|
+
if (!others.ok) return failed('ls-files', others)
|
|
593
|
+
if (!modified.ok) return failed('ls-files', modified)
|
|
594
|
+
|
|
595
|
+
const tree = workTree(root)
|
|
596
|
+
const isRegularFile = (path: string): boolean => {
|
|
597
|
+
try {
|
|
598
|
+
return lstatSync(join(tree, path)).isFile()
|
|
599
|
+
} catch {
|
|
600
|
+
return false
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const added = splitNul(others.text).filter(isRegularFile).sort()
|
|
605
|
+
const pending = [
|
|
606
|
+
...new Set([...added, ...splitNul(modified.text).filter(isRegularFile)]),
|
|
607
|
+
].sort()
|
|
608
|
+
|
|
609
|
+
return { added, pending }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function refuseBlocked(blocked: readonly BlockedPath[]): BackupRefused {
|
|
613
|
+
return {
|
|
614
|
+
...refuse(
|
|
615
|
+
'unsafe-payload',
|
|
616
|
+
[
|
|
617
|
+
`${blocked.length} record(s) cannot be backed up, so nothing was staged or pushed:`,
|
|
618
|
+
...blocked.map(
|
|
619
|
+
(finding) =>
|
|
620
|
+
` ${finding.path} (${finding.cause}: ${finding.detail})`,
|
|
621
|
+
),
|
|
622
|
+
'Move each out of the record folders, such as into .canon/tmp/, or remove the credential, then push again.',
|
|
623
|
+
].join('\n'),
|
|
624
|
+
),
|
|
625
|
+
blocked,
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Puts HEAD and the index back where they stood before this run committed.
|
|
631
|
+
*
|
|
632
|
+
* A mixed reset to the recorded HEAD rather than `reset --soft HEAD~1`. A soft
|
|
633
|
+
* reset leaves a dropped name's deletion staged while the next run's index read
|
|
634
|
+
* no longer lists the name, so that run's diff never sees the deletion and it
|
|
635
|
+
* is never committed. The recorded HEAD also covers the root commit, where
|
|
636
|
+
* `HEAD~1` does not exist and only removing the ref restores an unborn branch.
|
|
637
|
+
*
|
|
638
|
+
* Returns an error sentence when the undo itself failed, so the caller appends
|
|
639
|
+
* it rather than reporting a clean history that is not.
|
|
640
|
+
*/
|
|
641
|
+
async function undoCommit(
|
|
642
|
+
root: string,
|
|
643
|
+
priorHead: string,
|
|
644
|
+
): Promise<string | undefined> {
|
|
645
|
+
if (priorHead.length > 0) {
|
|
646
|
+
const reset = await records(root, ['reset', '--quiet', priorHead])
|
|
647
|
+
return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const unref = await records(root, ['update-ref', '-d', 'HEAD'])
|
|
651
|
+
if (!unref.ok) return `Undoing it failed: ${unref.stderr}.`
|
|
652
|
+
const reset = await records(root, ['reset', '--quiet'])
|
|
653
|
+
return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
|
|
654
|
+
}
|
|
655
|
+
|
|
534
656
|
/**
|
|
535
657
|
* Stages the backed folders, commits when any of them changed, and pushes.
|
|
536
658
|
*
|
|
537
|
-
* The
|
|
538
|
-
*
|
|
539
|
-
*
|
|
659
|
+
* The payload guard reads every pending file ahead of the stage, so a refusal
|
|
660
|
+
* leaves the index and the object store exactly as it found them. It reads what
|
|
661
|
+
* this run would add, never a commit already in the history.
|
|
662
|
+
*
|
|
663
|
+
* The push runs whether or not this call committed, because an older binary
|
|
664
|
+
* can have committed and then failed to reach the network. Skipping it would
|
|
665
|
+
* leave that commit on one disk, which is the state the whole verb exists to
|
|
666
|
+
* end. A commit this call made is undone when the push fails, so a remote that
|
|
667
|
+
* rejects the payload is not handed the same commit again on every later run.
|
|
540
668
|
*/
|
|
541
669
|
export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
542
670
|
const split = refuseSplitRoots(root)
|
|
@@ -546,27 +674,56 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
546
674
|
const remote = await resolveRemote(root, enclosing)
|
|
547
675
|
if (typeof remote !== 'string') return remote
|
|
548
676
|
|
|
549
|
-
const { scope, firstSeen } = await scopedFolders(root)
|
|
677
|
+
const { scope, firstSeen, dropped } = await scopedFolders(root)
|
|
678
|
+
|
|
679
|
+
const files = await pendingFiles(root, scope)
|
|
680
|
+
if ('ok' in files) return files
|
|
681
|
+
|
|
682
|
+
const blocked = await guardPayload(workTree(root), files.pending)
|
|
683
|
+
if (blocked.length > 0) return refuseBlocked(blocked)
|
|
550
684
|
|
|
551
685
|
if (scope.length > 0) {
|
|
552
686
|
// `-f` is what carries the payload: every backed folder is ignored by the
|
|
553
687
|
// enclosing repository, and the pathspecs are the whole list, so nothing
|
|
554
|
-
// outside them can enter the index however the ignore rules read.
|
|
688
|
+
// outside them can enter the index however the ignore rules read. An
|
|
689
|
+
// excluded name never reaches the list, whatever the records index holds.
|
|
555
690
|
const staged = await records(root, ['add', '-A', '-f', '--', ...scope])
|
|
556
691
|
if (!staged.ok) return failed('add', staged)
|
|
557
692
|
}
|
|
558
693
|
|
|
694
|
+
if (dropped.length > 0) {
|
|
695
|
+
const removed = await records(root, [
|
|
696
|
+
'rm',
|
|
697
|
+
'-r',
|
|
698
|
+
'--cached',
|
|
699
|
+
'--quiet',
|
|
700
|
+
'--',
|
|
701
|
+
...dropped,
|
|
702
|
+
])
|
|
703
|
+
if (!removed.ok) return failed('rm', removed)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Scope alone would miss the dropped deletions, which sit outside it.
|
|
559
707
|
const diff = await records(root, [
|
|
560
708
|
'diff',
|
|
561
709
|
'--cached',
|
|
562
710
|
'--name-only',
|
|
563
711
|
'--',
|
|
564
712
|
...scope,
|
|
713
|
+
...dropped,
|
|
565
714
|
])
|
|
566
715
|
if (!diff.ok) return failed('diff', diff)
|
|
567
716
|
|
|
568
717
|
const changed = countLines(diff.text)
|
|
569
718
|
|
|
719
|
+
const prior = await records(root, [
|
|
720
|
+
'rev-parse',
|
|
721
|
+
'--verify',
|
|
722
|
+
'--quiet',
|
|
723
|
+
'HEAD',
|
|
724
|
+
])
|
|
725
|
+
const priorHead = prior.ok ? prior.text : ''
|
|
726
|
+
|
|
570
727
|
if (changed > 0) {
|
|
571
728
|
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16)
|
|
572
729
|
const commit = await records(root, [
|
|
@@ -579,11 +736,17 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
579
736
|
if (!commit.ok) return failed('commit', commit)
|
|
580
737
|
}
|
|
581
738
|
|
|
582
|
-
const
|
|
739
|
+
const report = {
|
|
740
|
+
ok: true,
|
|
741
|
+
root,
|
|
742
|
+
folders: presentFolders(root),
|
|
743
|
+
firstSeen,
|
|
744
|
+
added: files.added,
|
|
745
|
+
dropped,
|
|
746
|
+
changed,
|
|
747
|
+
} as const
|
|
583
748
|
const head = await records(root, ['rev-parse', '--short', 'HEAD'])
|
|
584
|
-
if (!head.ok) {
|
|
585
|
-
return { ok: true, root, folders, firstSeen, changed, pushed: false }
|
|
586
|
-
}
|
|
749
|
+
if (!head.ok) return { ...report, pushed: false }
|
|
587
750
|
|
|
588
751
|
const branch = await projectBranch(root, enclosing)
|
|
589
752
|
const pushed = await records(root, [
|
|
@@ -591,17 +754,24 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
591
754
|
'origin',
|
|
592
755
|
`HEAD:refs/heads/${branch}`,
|
|
593
756
|
])
|
|
594
|
-
if (!pushed.ok)
|
|
757
|
+
if (!pushed.ok) {
|
|
758
|
+
const refused = failed('push', pushed)
|
|
759
|
+
if (changed === 0) return refused
|
|
595
760
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
761
|
+
const undoError = await undoCommit(root, priorHead)
|
|
762
|
+
return refuse(
|
|
763
|
+
'git-failed',
|
|
764
|
+
[
|
|
765
|
+
refused.message,
|
|
766
|
+
'The commit this run made was undone, and the records are still on disk.',
|
|
767
|
+
undoError,
|
|
768
|
+
]
|
|
769
|
+
.filter(Boolean)
|
|
770
|
+
.join(' '),
|
|
771
|
+
)
|
|
604
772
|
}
|
|
773
|
+
|
|
774
|
+
return { ...report, commit: head.text, pushed: true }
|
|
605
775
|
}
|
|
606
776
|
|
|
607
777
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isBinary } from '@/binary'
|
|
4
|
+
import { scanText } from '@/secrets/scan'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The largest single record a push carries, in bytes.
|
|
8
|
+
*
|
|
9
|
+
* A constant rather than a flag. The largest record measured in this
|
|
10
|
+
* repository is a 6.7 MB git pack under a groundwork fixture and GitHub
|
|
11
|
+
* rejects a blob at 100 MB, so this sits well clear of both, and an override
|
|
12
|
+
* is one nobody remembers to pass the first time a backup tarball lands in a
|
|
13
|
+
* record folder.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_RECORD_BYTES = 25 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
export interface BlockedPath {
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly cause: 'oversized' | 'credential'
|
|
20
|
+
/** A size or `<label> at line <n>`, never the matched value or a preview of it. */
|
|
21
|
+
readonly detail: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function megabytes(bytes: number): string {
|
|
25
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every pending path a push must not carry, in the order given.
|
|
30
|
+
*
|
|
31
|
+
* Reads the size off `lstat` first so an oversized file is never opened, and
|
|
32
|
+
* skips anything that is not a regular file, since `add` stages a symlink as
|
|
33
|
+
* its target path rather than the bytes it points at. The credential half is
|
|
34
|
+
* `scanText` unchanged, so the patterns and the `canon-allow-secret` marker
|
|
35
|
+
* answer here exactly as they do in `canon secrets scan`.
|
|
36
|
+
*/
|
|
37
|
+
export async function guardPayload(
|
|
38
|
+
tree: string,
|
|
39
|
+
paths: readonly string[],
|
|
40
|
+
): Promise<BlockedPath[]> {
|
|
41
|
+
const blocked: BlockedPath[] = []
|
|
42
|
+
|
|
43
|
+
for (const path of paths) {
|
|
44
|
+
const full = join(tree, path)
|
|
45
|
+
const stats = await lstat(full).catch(() => undefined)
|
|
46
|
+
if (!stats?.isFile()) continue
|
|
47
|
+
|
|
48
|
+
if (stats.size > MAX_RECORD_BYTES) {
|
|
49
|
+
blocked.push({
|
|
50
|
+
path,
|
|
51
|
+
cause: 'oversized',
|
|
52
|
+
detail: `${megabytes(stats.size)}, over the ${megabytes(MAX_RECORD_BYTES)} ceiling`,
|
|
53
|
+
})
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const text = await readFile(full, 'utf8').catch(() => undefined)
|
|
58
|
+
if (text === undefined || isBinary(text)) continue
|
|
59
|
+
|
|
60
|
+
const [first] = scanText(path, text)
|
|
61
|
+
if (first) {
|
|
62
|
+
blocked.push({
|
|
63
|
+
path,
|
|
64
|
+
cause: 'credential',
|
|
65
|
+
detail: `${first.label} at line ${first.line}`,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return blocked
|
|
71
|
+
}
|