@erclx/canon 4.112.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/canon-feedback/SKILL.md +3 -3
- 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 +8 -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 +3 -1
- package/src/claude/skills-audit.ts +2 -19
- package/src/commands/feedback-format.ts +24 -0
- package/src/commands/feedback.ts +2 -1
- package/src/commands/records.ts +18 -1
- package/src/commands/teach.ts +41 -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/indexes/frontmatter.ts +10 -1
- package/src/pr/evidence.ts +3 -1
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +75 -25
- package/src/teach/workspace.ts +14 -6
- 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/base/seeds/cspell.json +1 -1
- package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
- package/tooling/nextjs/configs/vitest.config.ts +1 -1
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/python/manifest.toml +1 -1
- package/tooling/python/reference.md +3 -4
- package/tooling/vite-react/configs/vite.config.ts +1 -1
- package/tooling/web/configs/scripts/verify.sh +32 -15
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { dirname, join } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
type Frontmatter,
|
|
5
|
-
parseFrontmatter,
|
|
6
|
-
readField,
|
|
7
|
-
} from '@/indexes/frontmatter'
|
|
3
|
+
import { parseFrontmatter, readField } from '@/indexes/frontmatter'
|
|
8
4
|
|
|
9
5
|
/** Returned when a skill folder carries no `REQUIREMENT.md`, the gating check. */
|
|
10
6
|
export const EXIT_MISSING_REQUIREMENT = 2
|
|
@@ -149,7 +145,7 @@ async function readSkill(
|
|
|
149
145
|
: Promise.resolve(undefined),
|
|
150
146
|
])
|
|
151
147
|
|
|
152
|
-
const fields =
|
|
148
|
+
const fields = parseFrontmatter(body)
|
|
153
149
|
|
|
154
150
|
return {
|
|
155
151
|
rel: join(rel, folder),
|
|
@@ -162,19 +158,6 @@ async function readSkill(
|
|
|
162
158
|
}
|
|
163
159
|
}
|
|
164
160
|
|
|
165
|
-
/**
|
|
166
|
-
* Returns undefined rather than throwing on unparseable YAML, so one malformed
|
|
167
|
-
* body reports as missing its required fields instead of ending the audit
|
|
168
|
-
* before it reaches the rest of the corpus.
|
|
169
|
-
*/
|
|
170
|
-
function readFields(body: string): Frontmatter | undefined {
|
|
171
|
-
try {
|
|
172
|
-
return parseFrontmatter(body)
|
|
173
|
-
} catch {
|
|
174
|
-
return undefined
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
161
|
/**
|
|
179
162
|
* A key carrying an empty value declares nothing, so blank reads as absent
|
|
180
163
|
* rather than as a name that disagrees with every folder.
|
|
@@ -89,6 +89,30 @@ export function deriveSlug(body: string): string {
|
|
|
89
89
|
return slug || SLUG_FALLBACK
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Each label must exist on the toolkit repository, because `gh issue create`
|
|
94
|
+
* refuses the whole issue on an unknown label rather than dropping it.
|
|
95
|
+
*/
|
|
96
|
+
const DOMAIN_LABELS: readonly { pattern: RegExp; label: string }[] = [
|
|
97
|
+
{ pattern: /\bskills?\b/, label: 'skills' },
|
|
98
|
+
{ pattern: /\b(?:cli)s?\b/, label: 'cli' },
|
|
99
|
+
{ pattern: /\b(?:tooling|seeds?)\b/, label: 'tooling' },
|
|
100
|
+
{ pattern: /\b(?:governance)s?\b/, label: 'governance' },
|
|
101
|
+
{ pattern: /\bsnippets?\b/, label: 'snippets' },
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads only the surface type, the text before the first comma, so a path or
|
|
106
|
+
* name after it never adds a label.
|
|
107
|
+
*/
|
|
108
|
+
export function deriveDomainLabels(body: string): string[] {
|
|
109
|
+
const type = (surfaceField(body) ?? '').split(',')[0]?.toLowerCase() ?? ''
|
|
110
|
+
const labels = DOMAIN_LABELS.filter(({ pattern }) => pattern.test(type)).map(
|
|
111
|
+
({ label }) => label,
|
|
112
|
+
)
|
|
113
|
+
return [...new Set(labels)]
|
|
114
|
+
}
|
|
115
|
+
|
|
92
116
|
export function deriveTitle(body: string): string {
|
|
93
117
|
const surface = surfaceField(body)
|
|
94
118
|
const title = surface ? `feedback: ${surface}` : 'toolkit feedback'
|
package/src/commands/feedback.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
4
|
import {
|
|
5
|
+
deriveDomainLabels,
|
|
5
6
|
deriveSlug,
|
|
6
7
|
deriveTitle,
|
|
7
8
|
missingField,
|
|
@@ -107,7 +108,7 @@ export function register(program: Command): void {
|
|
|
107
108
|
const result = await createGithubIssue({
|
|
108
109
|
title: deriveTitle(body),
|
|
109
110
|
body,
|
|
110
|
-
labels: ['feedback'],
|
|
111
|
+
labels: ['feedback', ...deriveDomainLabels(body)],
|
|
111
112
|
})
|
|
112
113
|
if (result.ok) {
|
|
113
114
|
frameSuccess('canon feedback', result.url)
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { relative } from 'node:path'
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type LessonOutcome, planLesson } from '@/teach/lesson'
|
|
4
4
|
import { type NavOutcome, generateNav } from '@/teach/nav'
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type TeachRefused,
|
|
19
19
|
type Term,
|
|
20
20
|
type TermOutcome,
|
|
21
|
+
teachDir,
|
|
21
22
|
type WorkspaceSummary,
|
|
22
23
|
writeStylesheet,
|
|
23
24
|
} from '@/teach/workspace'
|
|
@@ -91,6 +92,18 @@ export function register(program: Command): void {
|
|
|
91
92
|
.command('teach')
|
|
92
93
|
.description('Manage learning workspaces in .canon/teach/')
|
|
93
94
|
.helpOption('-h, --help', 'Show this help message')
|
|
95
|
+
.addHelpText(
|
|
96
|
+
'after',
|
|
97
|
+
[
|
|
98
|
+
'',
|
|
99
|
+
'Opening a workspace:',
|
|
100
|
+
' canon serve .canon/teach --entry <nn>-<topic>/index.html',
|
|
101
|
+
'',
|
|
102
|
+
'canon teach list <topic> prints the exact line, with the teach folder',
|
|
103
|
+
'written from where you stand rather than as this literal.',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
)
|
|
94
107
|
|
|
95
108
|
teach
|
|
96
109
|
.command('list')
|
|
@@ -118,6 +131,9 @@ export function register(program: Command): void {
|
|
|
118
131
|
'A workspace not named NN-<topic> is still listed. It sorts last and',
|
|
119
132
|
'moves no ordinal, since dropping it hides the folder needing a fix.',
|
|
120
133
|
'',
|
|
134
|
+
'The human output ends with the canon serve line that opens a',
|
|
135
|
+
'workspace, a template with no topic and the exact line with one.',
|
|
136
|
+
'',
|
|
121
137
|
'Examples:',
|
|
122
138
|
' canon teach list',
|
|
123
139
|
' canon teach list regular-expressions --json',
|
|
@@ -852,6 +868,22 @@ function describe(workspace: WorkspaceSummary): string {
|
|
|
852
868
|
return `${workspace.slug}: ${workspace.lessons} lesson(s), ${workspace.records} record(s), ${workspace.reference} reference page(s), ${workspace.terms} term(s)`
|
|
853
869
|
}
|
|
854
870
|
|
|
871
|
+
/**
|
|
872
|
+
* `canon serve` resolves its folder against the caller's cwd, while the list
|
|
873
|
+
* verb reads the teach folder at the main worktree root, so the line carries
|
|
874
|
+
* the folder actually read. A literal `.canon/teach` serves an absent folder
|
|
875
|
+
* from a linked worktree.
|
|
876
|
+
*/
|
|
877
|
+
function serveLine(root: string, entry: string): string {
|
|
878
|
+
const dir = resolve(teachDir(root))
|
|
879
|
+
const fromCwd = relative(process.cwd(), dir)
|
|
880
|
+
const isOutsideCwd =
|
|
881
|
+
fromCwd === '..' || fromCwd.startsWith(`..${sep}`) || isAbsolute(fromCwd)
|
|
882
|
+
const printed = fromCwd === '' ? '.' : isOutsideCwd ? dir : fromCwd
|
|
883
|
+
|
|
884
|
+
return `canon serve ${printed} --entry ${entry}`
|
|
885
|
+
}
|
|
886
|
+
|
|
855
887
|
function reportList(
|
|
856
888
|
outcome: ListOutcome,
|
|
857
889
|
emitJson: boolean,
|
|
@@ -890,6 +922,12 @@ function reportList(
|
|
|
890
922
|
|
|
891
923
|
logStep('Next ordinal')
|
|
892
924
|
logInfo(outcome.next)
|
|
925
|
+
|
|
926
|
+
if (outcome.workspaces.length > 0) {
|
|
927
|
+
logStep('Open')
|
|
928
|
+
logInfo(serveLine(root, '<nn>-<topic>/index.html'))
|
|
929
|
+
}
|
|
930
|
+
|
|
893
931
|
outro()
|
|
894
932
|
|
|
895
933
|
return 0
|
|
@@ -940,6 +978,8 @@ function reportWorkspace(
|
|
|
940
978
|
logWarn(`no ${workspace.missing.join(' and no ')}`)
|
|
941
979
|
}
|
|
942
980
|
|
|
981
|
+
logStep('Open')
|
|
982
|
+
logInfo(serveLine(root, `${workspace.slug}/index.html`))
|
|
943
983
|
outro()
|
|
944
984
|
|
|
945
985
|
return 0
|
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`
|
|
@@ -11,12 +11,21 @@ export interface Frontmatter {
|
|
|
11
11
|
* `raw` keeps the delimiters so an index can be rewritten with its own
|
|
12
12
|
* frontmatter untouched, including comments and key order that a parse and
|
|
13
13
|
* re-emit would lose.
|
|
14
|
+
*
|
|
15
|
+
* A block that will not parse reads as absent rather than throwing, so one
|
|
16
|
+
* malformed file reports through the path a caller already takes for a
|
|
17
|
+
* missing block instead of ending the caller's whole run.
|
|
14
18
|
*/
|
|
15
19
|
export function parseFrontmatter(source: string): Frontmatter | undefined {
|
|
16
20
|
const match = source.match(FRONTMATTER)
|
|
17
21
|
if (!match) return undefined
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
let parsed: unknown
|
|
24
|
+
try {
|
|
25
|
+
parsed = Bun.YAML.parse(match[1])
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
20
29
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
21
30
|
return undefined
|
|
22
31
|
}
|
package/src/pr/evidence.ts
CHANGED
|
@@ -106,8 +106,10 @@ export async function groupEvidence(
|
|
|
106
106
|
return { kind: 'read', states }
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// The raw host serves nothing to a private repository's browser, while the
|
|
110
|
+
// blob form redirects to the bytes carrying the viewer's session.
|
|
109
111
|
function rawUrl(repo: string, sha: string, path: string): string {
|
|
110
|
-
return `https://
|
|
112
|
+
return `https://github.com/${repo}/blob/${sha}/${path}?raw=true`
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
export function evidenceMarker(head: string): string {
|