@erclx/canon 4.81.0 → 4.83.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/docs-fold/REQUIREMENT.md +13 -0
- package/claude/skills/docs-fold/SKILL.md +20 -0
- package/claude/skills/docs-fold/references/classify.md +78 -0
- package/claude/skills/draft-slides/SKILL.md +1 -1
- package/claude/skills/draft-wireframes/REQUIREMENT.md +1 -1
- package/claude/skills/draft-wireframes/SKILL.md +7 -4
- package/docs/agents/commands.md +8 -4
- package/docs/agents/context-audit-checks.md +22 -2
- package/docs/agents/context-audit.md +13 -3
- package/docs/agents/context-classify.md +99 -0
- package/docs/agents/index.md +1 -0
- package/docs/target-projects.md +15 -0
- package/docs/workflow/ai-workflow.md +4 -3
- package/docs/workflow/visual-design-workflow.md +1 -1
- package/governance/rules/claude/520-wireframes.md +1 -1
- package/governance/rules/claude/545-decisions.md +12 -0
- package/package.json +1 -1
- package/src/claude/seeds.ts +1 -0
- package/src/commands/claude.ts +26 -5
- package/src/commands/context.ts +496 -2
- package/src/context/architecture.ts +73 -0
- package/src/context/audit.ts +18 -2
- package/src/context/classify/extract.ts +450 -0
- package/src/context/classify/ollama.ts +172 -0
- package/src/context/classify/patterns.ts +114 -0
- package/src/context/classify/prompts.ts +73 -0
- package/src/context/classify/run.ts +348 -0
- package/src/context/classify/settings.ts +196 -0
- package/src/context/folders.ts +1 -0
- package/src/context/gate.ts +44 -6
- package/src/context/wireframe-states.ts +238 -0
- package/src/surface-root.ts +1 -0
- package/standards/architecture.md +11 -1
- package/standards/context.md +4 -1
- package/standards/decisions.md +100 -0
- package/standards/design.md +6 -0
- package/standards/index.md +1 -0
- package/standards/requirements.md +2 -1
- package/standards/wireframes.md +45 -29
- package/tooling/claude/reference.md +2 -1
- package/tooling/claude/seeds/CLAUDE.md +2 -1
- package/tooling/claude/seeds/canon/decisions/index.md +8 -0
- package/tooling/claude/seeds/canon/wireframes/index.md +2 -2
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanonicalDocType,
|
|
3
|
+
extractDiffChunks,
|
|
4
|
+
extractSweepSections,
|
|
5
|
+
type ExtractRefusal,
|
|
6
|
+
} from '@/context/classify/extract'
|
|
7
|
+
import {
|
|
8
|
+
chat as ollamaChat,
|
|
9
|
+
type ChatOutcome,
|
|
10
|
+
DEFAULT_OLLAMA_BASE_URL,
|
|
11
|
+
probeOllama,
|
|
12
|
+
} from '@/context/classify/ollama'
|
|
13
|
+
import {
|
|
14
|
+
diffPatternVerdict,
|
|
15
|
+
type DiffVerdict,
|
|
16
|
+
type PatternVerdict,
|
|
17
|
+
sweepPatternVerdict,
|
|
18
|
+
type SweepVerdict,
|
|
19
|
+
} from '@/context/classify/patterns'
|
|
20
|
+
import {
|
|
21
|
+
DIFF_SYSTEM_PROMPT,
|
|
22
|
+
diffUserMessage,
|
|
23
|
+
SWEEP_SYSTEM_PROMPT,
|
|
24
|
+
sweepUserMessage,
|
|
25
|
+
} from '@/context/classify/prompts'
|
|
26
|
+
import {
|
|
27
|
+
type ClassifierBackend,
|
|
28
|
+
type ClassifierFlags,
|
|
29
|
+
type ClassifierResolution,
|
|
30
|
+
resolveClassifier,
|
|
31
|
+
type SettingSource,
|
|
32
|
+
} from '@/context/classify/settings'
|
|
33
|
+
|
|
34
|
+
const DIFF_VERDICTS: readonly DiffVerdict[] = [
|
|
35
|
+
'KEEP',
|
|
36
|
+
'REPLACE',
|
|
37
|
+
'HISTORY',
|
|
38
|
+
'MOVE',
|
|
39
|
+
]
|
|
40
|
+
const SWEEP_VERDICTS: readonly SweepVerdict[] = ['KEEP', 'REWRITE', 'MOVE']
|
|
41
|
+
|
|
42
|
+
function isDiffVerdict(value: string): value is DiffVerdict {
|
|
43
|
+
return (DIFF_VERDICTS as readonly string[]).includes(value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSweepVerdict(value: string): value is SweepVerdict {
|
|
47
|
+
return (SWEEP_VERDICTS as readonly string[]).includes(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A pattern verdict never carries a quote on KEEP, since nothing decided
|
|
52
|
+
* against the text. The finding's `regex` field is a `LayerVerdict` rather
|
|
53
|
+
* than a `PatternVerdict`, so it prints the same as a model layer verdict
|
|
54
|
+
* without every reader having to branch on an absent field.
|
|
55
|
+
*/
|
|
56
|
+
function withQuote<V extends string>(
|
|
57
|
+
verdict: PatternVerdict<V>,
|
|
58
|
+
): LayerVerdict<V> {
|
|
59
|
+
return {
|
|
60
|
+
verdict: verdict.verdict,
|
|
61
|
+
quote: verdict.quote ?? '',
|
|
62
|
+
reason: verdict.reason,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LayerVerdict<V extends string> {
|
|
67
|
+
readonly verdict: V
|
|
68
|
+
readonly quote: string
|
|
69
|
+
readonly reason: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why the model layer did not decide a run, named alongside `'ran'` so a
|
|
74
|
+
* caller reading the record never has to infer the reason from an absent
|
|
75
|
+
* field. `'off'` and `'skipped-no-model'` come from settings resolution
|
|
76
|
+
* alone; `'skipped-unreachable'` is the one state a probe call decides.
|
|
77
|
+
*/
|
|
78
|
+
export type ModelLayerState =
|
|
79
|
+
| 'off'
|
|
80
|
+
| 'ran'
|
|
81
|
+
| 'skipped-no-model'
|
|
82
|
+
| 'skipped-unreachable'
|
|
83
|
+
|
|
84
|
+
export interface DiffFinding {
|
|
85
|
+
readonly file: string
|
|
86
|
+
readonly docType: CanonicalDocType
|
|
87
|
+
readonly regex: LayerVerdict<DiffVerdict>
|
|
88
|
+
readonly model?: LayerVerdict<DiffVerdict>
|
|
89
|
+
/** Set when the model ran but its reply carried no verdict this could read. */
|
|
90
|
+
readonly modelUnparsed?: boolean
|
|
91
|
+
readonly verdict: DiffVerdict
|
|
92
|
+
readonly decidedBy: 'regex' | 'model'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface SweepFinding {
|
|
96
|
+
readonly file: string
|
|
97
|
+
readonly docType: CanonicalDocType
|
|
98
|
+
readonly heading: string
|
|
99
|
+
readonly regex: LayerVerdict<SweepVerdict>
|
|
100
|
+
readonly model?: LayerVerdict<SweepVerdict>
|
|
101
|
+
readonly modelUnparsed?: boolean
|
|
102
|
+
readonly verdict: SweepVerdict
|
|
103
|
+
readonly decidedBy: 'regex' | 'model'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ClassifyRecordBase {
|
|
107
|
+
readonly backend: ClassifierBackend | undefined
|
|
108
|
+
readonly model: string | undefined
|
|
109
|
+
readonly modelLayer: ModelLayerState
|
|
110
|
+
readonly settingsSource: SettingSource
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface DiffRecord extends ClassifyRecordBase {
|
|
114
|
+
readonly mode: 'diff'
|
|
115
|
+
readonly findings: readonly DiffFinding[]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface SweepRecord extends ClassifyRecordBase {
|
|
119
|
+
readonly mode: 'sweep'
|
|
120
|
+
readonly findings: readonly SweepFinding[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type ClassifyRefusal = ExtractRefusal
|
|
124
|
+
|
|
125
|
+
export type ClassifyOutcome<Record> =
|
|
126
|
+
| { readonly kind: 'ok'; readonly record: Record }
|
|
127
|
+
| {
|
|
128
|
+
readonly kind: 'refused'
|
|
129
|
+
readonly reason: ClassifyRefusal
|
|
130
|
+
readonly message: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The network boundary, injected so a test drives the merge logic below
|
|
135
|
+
* without a live Ollama. `src/version/skew.ts` takes the same shape for the
|
|
136
|
+
* npm registry lookup it wraps.
|
|
137
|
+
*/
|
|
138
|
+
export interface ModelClient {
|
|
139
|
+
readonly probe: (baseUrl: string, timeoutMs?: number) => Promise<boolean>
|
|
140
|
+
readonly chat: (opts: {
|
|
141
|
+
readonly baseUrl: string
|
|
142
|
+
readonly model: string
|
|
143
|
+
readonly system: string
|
|
144
|
+
readonly user: string
|
|
145
|
+
readonly timeoutMs?: number
|
|
146
|
+
}) => Promise<ChatOutcome>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const DEFAULT_CLIENT: ModelClient = { probe: probeOllama, chat: ollamaChat }
|
|
150
|
+
|
|
151
|
+
export interface ClassifyOptions {
|
|
152
|
+
readonly flags?: ClassifierFlags
|
|
153
|
+
readonly docTypes?: readonly CanonicalDocType[]
|
|
154
|
+
readonly baseUrl?: string
|
|
155
|
+
readonly client?: ModelClient
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ModelLayerPlan {
|
|
159
|
+
readonly modelLayer: ModelLayerState
|
|
160
|
+
readonly backend?: ClassifierBackend
|
|
161
|
+
readonly model?: string
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Decides whether the model layer runs, probing reachability before any real
|
|
166
|
+
* call. A configured-but-unreachable backend is a plan the caller reports and
|
|
167
|
+
* falls back from, never a refusal: the groundwork decision reads "warn and
|
|
168
|
+
* continue," and a refused run here would fail `docs-fold` for a reason that
|
|
169
|
+
* has nothing to do with the branch it is checking.
|
|
170
|
+
*/
|
|
171
|
+
async function planModelLayer(
|
|
172
|
+
resolution: ClassifierResolution,
|
|
173
|
+
client: ModelClient,
|
|
174
|
+
baseUrl: string,
|
|
175
|
+
): Promise<ModelLayerPlan> {
|
|
176
|
+
if (resolution.kind === 'off') return { modelLayer: 'off' }
|
|
177
|
+
if (resolution.kind === 'no-model') {
|
|
178
|
+
return { modelLayer: 'skipped-no-model', backend: resolution.backend }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const reachable = await client.probe(baseUrl)
|
|
182
|
+
if (!reachable) {
|
|
183
|
+
return {
|
|
184
|
+
modelLayer: 'skipped-unreachable',
|
|
185
|
+
backend: resolution.backend,
|
|
186
|
+
model: resolution.model,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
modelLayer: 'ran',
|
|
192
|
+
backend: resolution.backend,
|
|
193
|
+
model: resolution.model,
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One chunk or section, one model call, matching the measured constraint that
|
|
199
|
+
* batching multiple items into one prompt returned KEEP for everything. The
|
|
200
|
+
* caller (`classifyDiff`/`classifySweep`) awaits this once per item rather
|
|
201
|
+
* than issuing every call in parallel, since the items already share one
|
|
202
|
+
* backend and the groundwork measurements are all serial per-call numbers.
|
|
203
|
+
*/
|
|
204
|
+
async function modelVerdictFor<V extends string>(opts: {
|
|
205
|
+
readonly plan: ModelLayerPlan
|
|
206
|
+
readonly client: ModelClient
|
|
207
|
+
readonly baseUrl: string
|
|
208
|
+
readonly system: string
|
|
209
|
+
readonly user: string
|
|
210
|
+
readonly isVerdict: (value: string) => value is V
|
|
211
|
+
}): Promise<{
|
|
212
|
+
readonly verdict?: LayerVerdict<V>
|
|
213
|
+
readonly unparsed: boolean
|
|
214
|
+
}> {
|
|
215
|
+
if (opts.plan.modelLayer !== 'ran' || !opts.plan.model) {
|
|
216
|
+
return { unparsed: false }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const outcome = await opts.client.chat({
|
|
220
|
+
baseUrl: opts.baseUrl,
|
|
221
|
+
model: opts.plan.model,
|
|
222
|
+
system: opts.system,
|
|
223
|
+
user: opts.user,
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if (outcome.kind !== 'ok' || !opts.isVerdict(outcome.parsed.verdict)) {
|
|
227
|
+
return { unparsed: true }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
verdict: {
|
|
232
|
+
verdict: outcome.parsed.verdict,
|
|
233
|
+
quote: outcome.parsed.quote,
|
|
234
|
+
reason: outcome.parsed.reason,
|
|
235
|
+
},
|
|
236
|
+
unparsed: false,
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Runs diff mode over one git range: the regex layer over every extracted
|
|
242
|
+
* chunk, and the model layer over the same chunks when configured and
|
|
243
|
+
* reachable.
|
|
244
|
+
*
|
|
245
|
+
* A finding's `verdict` takes the model's reading when the model ran and
|
|
246
|
+
* parsed, and the regex reading otherwise, since the groundwork measurements
|
|
247
|
+
* show the model catching what the regex layer structurally cannot
|
|
248
|
+
* (REPLACE, and MOVE outside a wireframe). Both readings stay on the finding
|
|
249
|
+
* regardless of which one decided, so a caller can see where they disagreed.
|
|
250
|
+
*/
|
|
251
|
+
export async function classifyDiff(
|
|
252
|
+
root: string,
|
|
253
|
+
ref: string | undefined,
|
|
254
|
+
opts: ClassifyOptions = {},
|
|
255
|
+
): Promise<ClassifyOutcome<DiffRecord>> {
|
|
256
|
+
const extraction = await extractDiffChunks(root, ref, opts.docTypes)
|
|
257
|
+
if (extraction.kind === 'refused') return extraction
|
|
258
|
+
|
|
259
|
+
const resolution = resolveClassifier(root, opts.flags ?? {})
|
|
260
|
+
const client = opts.client ?? DEFAULT_CLIENT
|
|
261
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
|
|
262
|
+
const plan = await planModelLayer(resolution, client, baseUrl)
|
|
263
|
+
|
|
264
|
+
const findings: DiffFinding[] = []
|
|
265
|
+
for (const chunk of extraction.chunks) {
|
|
266
|
+
const regex = withQuote(diffPatternVerdict(chunk.file, chunk.added))
|
|
267
|
+
const model = await modelVerdictFor({
|
|
268
|
+
plan,
|
|
269
|
+
client,
|
|
270
|
+
baseUrl,
|
|
271
|
+
system: DIFF_SYSTEM_PROMPT,
|
|
272
|
+
user: diffUserMessage(chunk),
|
|
273
|
+
isVerdict: isDiffVerdict,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
findings.push({
|
|
277
|
+
file: chunk.file,
|
|
278
|
+
docType: chunk.docType,
|
|
279
|
+
regex,
|
|
280
|
+
model: model.verdict,
|
|
281
|
+
modelUnparsed: model.unparsed || undefined,
|
|
282
|
+
verdict: model.verdict?.verdict ?? regex.verdict,
|
|
283
|
+
decidedBy: model.verdict ? 'model' : 'regex',
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
kind: 'ok',
|
|
289
|
+
record: {
|
|
290
|
+
mode: 'diff',
|
|
291
|
+
backend: plan.backend,
|
|
292
|
+
model: plan.model,
|
|
293
|
+
modelLayer: plan.modelLayer,
|
|
294
|
+
settingsSource: resolution.source,
|
|
295
|
+
findings,
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Runs sweep mode over every extracted section, mirroring `classifyDiff`. */
|
|
301
|
+
export async function classifySweep(
|
|
302
|
+
root: string,
|
|
303
|
+
opts: ClassifyOptions = {},
|
|
304
|
+
): Promise<ClassifyOutcome<SweepRecord>> {
|
|
305
|
+
const extraction = await extractSweepSections(root, opts.docTypes)
|
|
306
|
+
if (extraction.kind === 'refused') return extraction
|
|
307
|
+
|
|
308
|
+
const resolution = resolveClassifier(root, opts.flags ?? {})
|
|
309
|
+
const client = opts.client ?? DEFAULT_CLIENT
|
|
310
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_OLLAMA_BASE_URL
|
|
311
|
+
const plan = await planModelLayer(resolution, client, baseUrl)
|
|
312
|
+
|
|
313
|
+
const findings: SweepFinding[] = []
|
|
314
|
+
for (const section of extraction.sections) {
|
|
315
|
+
const regex = withQuote(sweepPatternVerdict(section.file, section.body))
|
|
316
|
+
const model = await modelVerdictFor({
|
|
317
|
+
plan,
|
|
318
|
+
client,
|
|
319
|
+
baseUrl,
|
|
320
|
+
system: SWEEP_SYSTEM_PROMPT,
|
|
321
|
+
user: sweepUserMessage(section),
|
|
322
|
+
isVerdict: isSweepVerdict,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
findings.push({
|
|
326
|
+
file: section.file,
|
|
327
|
+
docType: section.docType,
|
|
328
|
+
heading: section.heading,
|
|
329
|
+
regex,
|
|
330
|
+
model: model.verdict,
|
|
331
|
+
modelUnparsed: model.unparsed || undefined,
|
|
332
|
+
verdict: model.verdict?.verdict ?? regex.verdict,
|
|
333
|
+
decidedBy: model.verdict ? 'model' : 'regex',
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
kind: 'ok',
|
|
339
|
+
record: {
|
|
340
|
+
mode: 'sweep',
|
|
341
|
+
backend: plan.backend,
|
|
342
|
+
model: plan.model,
|
|
343
|
+
modelLayer: plan.modelLayer,
|
|
344
|
+
settingsSource: resolution.source,
|
|
345
|
+
findings,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Ollama is the only backend. The groundwork decision explicitly deferred a
|
|
13
|
+
* Haiku or Claude API backend, so this union has one member rather than
|
|
14
|
+
* carrying an unused second case a switch would have to handle.
|
|
15
|
+
*/
|
|
16
|
+
export type ClassifierBackend = 'ollama'
|
|
17
|
+
|
|
18
|
+
/** Where `set` writes and `classify`/`classifier show` read the project setting. */
|
|
19
|
+
export const CLASSIFIER_CONFIG_REL = join('canon', 'config', 'classifier.toml')
|
|
20
|
+
|
|
21
|
+
export type SettingSource = 'flag' | 'env' | 'file' | 'default'
|
|
22
|
+
|
|
23
|
+
export interface ClassifierFlags {
|
|
24
|
+
readonly backend?: string
|
|
25
|
+
readonly model?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface FileSettings {
|
|
29
|
+
readonly backend?: string
|
|
30
|
+
readonly model?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readFileSettings(root: string): FileSettings {
|
|
34
|
+
const path = join(root, CLASSIFIER_CONFIG_REL)
|
|
35
|
+
if (!existsSync(path)) return {}
|
|
36
|
+
|
|
37
|
+
let parsed: Record<string, unknown>
|
|
38
|
+
try {
|
|
39
|
+
parsed = Bun.TOML.parse(readFileSync(path, 'utf8')) as Record<
|
|
40
|
+
string,
|
|
41
|
+
unknown
|
|
42
|
+
>
|
|
43
|
+
} catch {
|
|
44
|
+
return {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const table = parsed.classifier
|
|
48
|
+
if (typeof table !== 'object' || table === null) return {}
|
|
49
|
+
const { backend, model } = table as Record<string, unknown>
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
backend: typeof backend === 'string' ? backend : undefined,
|
|
53
|
+
model: typeof model === 'string' ? model : undefined,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBackend(value: string): value is ClassifierBackend {
|
|
58
|
+
return value === 'ollama'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves the backend through the stated precedence: flag, then the
|
|
63
|
+
* `CANON_CLASSIFIER_BACKEND` environment variable, then the project config
|
|
64
|
+
* file, then off. `off` is a value in its own right rather than an absence,
|
|
65
|
+
* since a project's config file setting `backend = "off"` states the same
|
|
66
|
+
* fact the missing-file default does, and the source line should say which
|
|
67
|
+
* one decided it.
|
|
68
|
+
*
|
|
69
|
+
* An unrecognized value at any tier is read the same as an absent one, and
|
|
70
|
+
* falls through to the next tier rather than refusing. That keeps a typo'd
|
|
71
|
+
* environment variable from silently making the whole precedence chain
|
|
72
|
+
* unreachable underneath it.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveBackend(
|
|
75
|
+
root: string,
|
|
76
|
+
flags: ClassifierFlags,
|
|
77
|
+
): {
|
|
78
|
+
readonly value: ClassifierBackend | 'off'
|
|
79
|
+
readonly source: SettingSource
|
|
80
|
+
} {
|
|
81
|
+
if (flags.backend !== undefined) {
|
|
82
|
+
if (flags.backend === 'off') return { value: 'off', source: 'flag' }
|
|
83
|
+
if (isBackend(flags.backend))
|
|
84
|
+
return { value: flags.backend, source: 'flag' }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const envBackend = process.env.CANON_CLASSIFIER_BACKEND
|
|
88
|
+
if (envBackend !== undefined) {
|
|
89
|
+
if (envBackend === 'off') return { value: 'off', source: 'env' }
|
|
90
|
+
if (isBackend(envBackend)) return { value: envBackend, source: 'env' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fileBackend = readFileSettings(root).backend
|
|
94
|
+
if (fileBackend !== undefined) {
|
|
95
|
+
if (fileBackend === 'off') return { value: 'off', source: 'file' }
|
|
96
|
+
if (isBackend(fileBackend)) return { value: fileBackend, source: 'file' }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return { value: 'off', source: 'default' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolves the model name through the same precedence as the backend, kept as
|
|
104
|
+
* a separate function since a caller can name a backend without a model (the
|
|
105
|
+
* `no-model` state `resolveClassifier` reports) or a model without a backend
|
|
106
|
+
* (which resolves nothing, since there is nothing to run it against).
|
|
107
|
+
*/
|
|
108
|
+
export function resolveModel(
|
|
109
|
+
root: string,
|
|
110
|
+
flags: ClassifierFlags,
|
|
111
|
+
): { readonly value: string; readonly source: SettingSource } | undefined {
|
|
112
|
+
if (flags.model !== undefined && flags.model !== '') {
|
|
113
|
+
return { value: flags.model, source: 'flag' }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const envModel = process.env.CANON_CLASSIFIER_MODEL
|
|
117
|
+
if (envModel !== undefined && envModel !== '') {
|
|
118
|
+
return { value: envModel, source: 'env' }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const fileModel = readFileSettings(root).model
|
|
122
|
+
if (fileModel !== undefined && fileModel !== '') {
|
|
123
|
+
return { value: fileModel, source: 'file' }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export type ClassifierResolution =
|
|
130
|
+
| { readonly kind: 'off'; readonly source: SettingSource }
|
|
131
|
+
| {
|
|
132
|
+
readonly kind: 'no-model'
|
|
133
|
+
readonly backend: ClassifierBackend
|
|
134
|
+
readonly source: SettingSource
|
|
135
|
+
}
|
|
136
|
+
| {
|
|
137
|
+
readonly kind: 'configured'
|
|
138
|
+
readonly backend: ClassifierBackend
|
|
139
|
+
readonly model: string
|
|
140
|
+
/** The source that decided the backend, which decided the model layer runs at all. */
|
|
141
|
+
readonly source: SettingSource
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Combines the two precedence reads into the one answer `classify` and
|
|
146
|
+
* `classifier show` both need: whether the model layer runs, and why.
|
|
147
|
+
*
|
|
148
|
+
* No default model name exists on purpose. The groundwork decision reads: a
|
|
149
|
+
* model name on one machine means nothing on another, so `show` reports the
|
|
150
|
+
* gap and `classify` warns and falls back to the regex layer rather than
|
|
151
|
+
* guessing a name that may not be pulled.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveClassifier(
|
|
154
|
+
root: string,
|
|
155
|
+
flags: ClassifierFlags = {},
|
|
156
|
+
): ClassifierResolution {
|
|
157
|
+
const backend = resolveBackend(root, flags)
|
|
158
|
+
if (backend.value === 'off') return { kind: 'off', source: backend.source }
|
|
159
|
+
|
|
160
|
+
const model = resolveModel(root, flags)
|
|
161
|
+
if (model === undefined) {
|
|
162
|
+
return { kind: 'no-model', backend: backend.value, source: backend.source }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
kind: 'configured',
|
|
167
|
+
backend: backend.value,
|
|
168
|
+
model: model.value,
|
|
169
|
+
source: backend.source,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Writes the project's classifier setting, creating `canon/config/` when a
|
|
175
|
+
* project does not carry it yet. `pr-labels.toml` is the one precedent for a
|
|
176
|
+
* `canon/config/` file and it is hand-authored and read-only to the CLI, so
|
|
177
|
+
* this is the first write path into that folder and the first TOML file this
|
|
178
|
+
* CLI generates rather than parses.
|
|
179
|
+
*
|
|
180
|
+
* Preserves an existing file's mode per the operator-file convention, so a
|
|
181
|
+
* destination an operator locked down keeps its permissions across a rewrite.
|
|
182
|
+
*/
|
|
183
|
+
export function writeClassifierConfig(
|
|
184
|
+
root: string,
|
|
185
|
+
backend: ClassifierBackend | 'off',
|
|
186
|
+
model?: string,
|
|
187
|
+
): void {
|
|
188
|
+
const path = join(root, CLASSIFIER_CONFIG_REL)
|
|
189
|
+
const mode = existsSync(path) ? statSync(path).mode : undefined
|
|
190
|
+
|
|
191
|
+
const modelLine = model ? `model = "${model}"\n` : ''
|
|
192
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
193
|
+
writeFileSync(path, `[classifier]\nbackend = "${backend}"\n${modelLine}`)
|
|
194
|
+
|
|
195
|
+
if (mode !== undefined) chmodSync(path, mode)
|
|
196
|
+
}
|
package/src/context/folders.ts
CHANGED
package/src/context/gate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SectionFinding } from '@/context/audit'
|
|
2
2
|
import type { FolderDrift } from '@/context/index-drift'
|
|
3
|
+
import type { WireframeStatesReport } from '@/context/wireframe-states'
|
|
3
4
|
|
|
4
5
|
export interface GateInput {
|
|
5
6
|
/** Cited paths that resolved to nothing, which gate under either mode. */
|
|
@@ -15,6 +16,22 @@ export interface GateInput {
|
|
|
15
16
|
readonly recordOverLength: boolean
|
|
16
17
|
readonly sections: readonly SectionFinding[]
|
|
17
18
|
readonly drift: readonly FolderDrift[]
|
|
19
|
+
/**
|
|
20
|
+
* Every wireframe entry's states report, empty for a project carrying no
|
|
21
|
+
* `canon/wireframes/` folder. A states/evidence mismatch is a fact read off
|
|
22
|
+
* the file, the same standing the missing-section and index-drift findings
|
|
23
|
+
* already have, so it gates alongside them under the widened mode.
|
|
24
|
+
*
|
|
25
|
+
* A sketch beside existing evidence stays out of the gate on both modes.
|
|
26
|
+
* `sketchWithEvidence` reads the whole entry against whether any of its
|
|
27
|
+
* states has evidence, not the sketched layout against the evidence for
|
|
28
|
+
* that same layout, since the standard's `## States` table names states
|
|
29
|
+
* rather than layouts and the report has no narrower unit to match on. An
|
|
30
|
+
* entry with a captured default layout and a sketch for a breakpoint layout
|
|
31
|
+
* that is not built yet is conforming and still trips this reading, so the
|
|
32
|
+
* finding stays a judgment for a reader rather than a fact a push fails on.
|
|
33
|
+
*/
|
|
34
|
+
readonly wireframes: readonly WireframeStatesReport[]
|
|
18
35
|
/**
|
|
19
36
|
* Whether the caller asked for the widened gate. False leaves a missing
|
|
20
37
|
* section and a drifted index advisory, which is what the project-root stage
|
|
@@ -30,28 +47,49 @@ export function hasDrift(drift: readonly FolderDrift[]): boolean {
|
|
|
30
47
|
)
|
|
31
48
|
}
|
|
32
49
|
|
|
50
|
+
/** Whether any wireframe's States table disagrees with its evidence folders. */
|
|
51
|
+
export function hasStatesMismatch(
|
|
52
|
+
wireframes: readonly WireframeStatesReport[],
|
|
53
|
+
): boolean {
|
|
54
|
+
return wireframes.some(
|
|
55
|
+
(entry) =>
|
|
56
|
+
entry.missingFolders.length > 0 || entry.unlistedFolders.length > 0,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether any wireframe carries a sketch beside evidence that already exists. */
|
|
61
|
+
export function hasSketchWithEvidence(
|
|
62
|
+
wireframes: readonly WireframeStatesReport[],
|
|
63
|
+
): boolean {
|
|
64
|
+
return wireframes.some((entry) => entry.sketchWithEvidence)
|
|
65
|
+
}
|
|
66
|
+
|
|
33
67
|
/**
|
|
34
68
|
* Whether the audit found something that should fail the caller.
|
|
35
69
|
*
|
|
36
70
|
* An unresolved citation is a broken pointer and gates unconditionally, and so
|
|
37
71
|
* does a record past its own ceiling: the record states the limit for itself
|
|
38
72
|
* and derives it from a count, which makes it the one measure here that is a
|
|
39
|
-
* fact rather than a threshold a reader weighs. The
|
|
40
|
-
*
|
|
41
|
-
* declare,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
73
|
+
* fact rather than a threshold a reader weighs. The findings `--gate` adds are
|
|
74
|
+
* the ones answerable from the file itself: a required section it does not
|
|
75
|
+
* declare, an index disagreeing with its folder, and a wireframe's States
|
|
76
|
+
* table disagreeing with its evidence folders. Entry length, depth, bullet,
|
|
77
|
+
* table, provenance, the record's claim coverage, and a sketch beside
|
|
78
|
+
* existing evidence are judgments, so they stay out under both modes. The
|
|
79
|
+
* `wireframes` field's own doc states why the sketch finding is a judgment
|
|
80
|
+
* rather than a fact.
|
|
44
81
|
*/
|
|
45
82
|
export function isGating({
|
|
46
83
|
unresolvedCitations,
|
|
47
84
|
recordOverLength,
|
|
48
85
|
sections,
|
|
49
86
|
drift,
|
|
87
|
+
wireframes,
|
|
50
88
|
widened,
|
|
51
89
|
}: GateInput): boolean {
|
|
52
90
|
if (unresolvedCitations > 0) return true
|
|
53
91
|
if (recordOverLength) return true
|
|
54
92
|
if (!widened) return false
|
|
55
93
|
|
|
56
|
-
return sections.length > 0 || hasDrift(drift)
|
|
94
|
+
return sections.length > 0 || hasDrift(drift) || hasStatesMismatch(wireframes)
|
|
57
95
|
}
|