@erclx/aitk 1.3.0 → 1.5.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/docs/agents/context-audit-checks.md +18 -2
- package/docs/agents/context-audit.md +3 -3
- package/docs/agents/index.md +1 -1
- package/docs/agents/output-shape.md +13 -0
- package/docs/visual-design-workflow.md +2 -0
- package/package.json +1 -1
- package/src/audits/catalog.ts +62 -1
- package/src/cli.ts +4 -4
- package/src/commands/claude.ts +4 -4
- package/src/commands/context.ts +124 -6
- package/src/commands/design.ts +2 -6
- package/src/commands/feedback.ts +2 -4
- package/src/commands/gov.ts +3 -3
- package/src/commands/init.ts +3 -5
- package/src/commands/slides.ts +4 -6
- package/src/commands/snippets.ts +2 -3
- package/src/commands/standards.ts +2 -4
- package/src/commands/sync.ts +3 -4
- package/src/commands/tooling.ts +2 -3
- package/src/commands/transcripts.ts +2 -6
- package/src/commands/wiki.ts +2 -3
- package/src/context/architecture.ts +364 -0
- package/src/context/gate.ts +19 -5
- package/src/design/parse.ts +42 -5
- package/src/design/render.ts +106 -27
- package/src/sync/engine.ts +3 -4
- package/src/sync/workflow.ts +2 -3
- package/src/ui.ts +66 -6
- package/standards/design.md +10 -0
package/src/commands/sync.ts
CHANGED
|
@@ -29,13 +29,10 @@ import {
|
|
|
29
29
|
logStep,
|
|
30
30
|
logWarn,
|
|
31
31
|
outro,
|
|
32
|
+
palette,
|
|
32
33
|
} from '@/ui'
|
|
33
34
|
import { describeSkew } from '@/version/skew'
|
|
34
35
|
|
|
35
|
-
const GREY = '\x1b[0;90m'
|
|
36
|
-
const YELLOW = '\x1b[0;33m'
|
|
37
|
-
const NC = '\x1b[0m'
|
|
38
|
-
|
|
39
36
|
const SYNC_ARGS: Record<SyncDomain, readonly string[]> = {
|
|
40
37
|
standards: ['standards', 'sync'],
|
|
41
38
|
snippets: ['snippets', 'sync'],
|
|
@@ -180,6 +177,7 @@ function renderCheck(report: CheckReport): void {
|
|
|
180
177
|
)
|
|
181
178
|
if (uncovered.length === 0) return
|
|
182
179
|
|
|
180
|
+
const { GREY, NC } = palette(process.stderr)
|
|
183
181
|
process.stderr.write(
|
|
184
182
|
`${GREY}Unstamped: ${uncovered.join(', ')}. Run the matching sync to record one.${NC}\n`,
|
|
185
183
|
)
|
|
@@ -338,6 +336,7 @@ async function runSync(target: string): Promise<number> {
|
|
|
338
336
|
if (typeof resolved === 'number') return resolved
|
|
339
337
|
|
|
340
338
|
const git = createGitRunner(resolved)
|
|
339
|
+
const { GREY, NC, YELLOW } = palette(process.stderr)
|
|
341
340
|
|
|
342
341
|
logStep('Checking working tree')
|
|
343
342
|
if (!isTreeClean(await git.status([]))) {
|
package/src/commands/tooling.ts
CHANGED
|
@@ -28,12 +28,10 @@ import {
|
|
|
28
28
|
logStep,
|
|
29
29
|
logWarn,
|
|
30
30
|
outro,
|
|
31
|
+
palette,
|
|
31
32
|
select,
|
|
32
33
|
} from '@/ui'
|
|
33
34
|
|
|
34
|
-
const GREEN = '\x1b[0;32m'
|
|
35
|
-
const NC = '\x1b[0m'
|
|
36
|
-
|
|
37
35
|
const PASS_THROUGH_VERBS = ['ref', 'create', 'verify'] as const
|
|
38
36
|
|
|
39
37
|
interface SyncOptions {
|
|
@@ -256,6 +254,7 @@ async function runSync(
|
|
|
256
254
|
report(result, includeReferences)
|
|
257
255
|
|
|
258
256
|
const mode = resolveWriteMode(opts)
|
|
257
|
+
const { GREEN, NC } = palette(process.stderr)
|
|
259
258
|
|
|
260
259
|
if (result.totalChanges === 0) {
|
|
261
260
|
// The stamp is a write like any other, so a run with no authority to write
|
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
|
|
4
|
-
|
|
5
|
-
const GREY = '\x1b[0;90m'
|
|
6
|
-
const WHITE = '\x1b[1;37m'
|
|
7
|
-
const RED = '\x1b[0;31m'
|
|
8
|
-
const GREEN = '\x1b[0;32m'
|
|
9
|
-
const NC = '\x1b[0m'
|
|
4
|
+
import { palette } from '@/ui'
|
|
10
5
|
|
|
11
6
|
interface TranscriptOptions {
|
|
12
7
|
out: string
|
|
@@ -24,6 +19,7 @@ export function register(program: Command): void {
|
|
|
24
19
|
)
|
|
25
20
|
.action(async (url: string, opts: TranscriptOptions) => {
|
|
26
21
|
const outDir = resolve(process.cwd(), opts.out)
|
|
22
|
+
const { GREEN, GREY, NC, RED, WHITE } = palette(process.stderr)
|
|
27
23
|
process.stderr.write(
|
|
28
24
|
`${GREY}┌${NC}\n${GREY}│${NC} ${WHITE}aitk transcripts${NC}\n`,
|
|
29
25
|
)
|
package/src/commands/wiki.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
logStep,
|
|
10
10
|
logWarn,
|
|
11
11
|
outro,
|
|
12
|
+
palette,
|
|
12
13
|
select,
|
|
13
14
|
} from '@/ui'
|
|
14
15
|
import {
|
|
@@ -20,9 +21,6 @@ import {
|
|
|
20
21
|
WIKI_INDEX_REL,
|
|
21
22
|
} from '@/wiki/init'
|
|
22
23
|
|
|
23
|
-
const GREEN = '\x1b[0;32m'
|
|
24
|
-
const NC = '\x1b[0m'
|
|
25
|
-
|
|
26
24
|
export function register(program: Command): void {
|
|
27
25
|
const wiki = program
|
|
28
26
|
.command('wiki')
|
|
@@ -59,6 +57,7 @@ async function runInit(target: string): Promise<number> {
|
|
|
59
57
|
}
|
|
60
58
|
|
|
61
59
|
const plan = planWikiInit(resolved)
|
|
60
|
+
const { GREEN, NC } = palette(process.stderr)
|
|
62
61
|
|
|
63
62
|
logStep(`Scanning ${WIKI_DIR_REL}`)
|
|
64
63
|
if (plan.changes.includes('dir')) logAdd(WIKI_DIR_REL)
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { access, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { AUDITS } from '@/audits/catalog'
|
|
4
|
+
import { bodyLines } from '@/markdown/scan'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The record this measures, relative to the project root.
|
|
8
|
+
*
|
|
9
|
+
* One fixed path rather than a folder walk, because the standard governing it
|
|
10
|
+
* names one document, and the length rule this measures is stated by whichever
|
|
11
|
+
* record sits there rather than by the standard or by this file.
|
|
12
|
+
*/
|
|
13
|
+
export const RECORD_REL = '.claude/ARCHITECTURE.md'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The line allowances a record states for itself, absent when it states none.
|
|
17
|
+
*
|
|
18
|
+
* No standard sets a length rule for this document, so the numbers belong to
|
|
19
|
+
* whichever record declares them rather than to the toolkit. Holding a pair in
|
|
20
|
+
* code and gating every project against it audits a target against a rule it
|
|
21
|
+
* never adopted, which is the failure `canResolveAtRoot` already answers on the
|
|
22
|
+
* folder side. A record stating no rule is measured and never gated.
|
|
23
|
+
*/
|
|
24
|
+
export interface Allowances {
|
|
25
|
+
readonly frame: number
|
|
26
|
+
readonly perDecision: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What a machine could do with the entry's reasoning, which is a candidate
|
|
31
|
+
* rather than a verdict.
|
|
32
|
+
*
|
|
33
|
+
* - `countable`: carries a figure over a tree, so a run could recompute it.
|
|
34
|
+
* - `invariant`: quantifies over a named tree, so a walk could falsify it.
|
|
35
|
+
* - `neither`: says why a choice was made, which nothing tests.
|
|
36
|
+
*/
|
|
37
|
+
export type ClaimKind = 'countable' | 'invariant' | 'neither'
|
|
38
|
+
|
|
39
|
+
export interface DecisionReport {
|
|
40
|
+
readonly heading: string
|
|
41
|
+
/** Line of the `###` heading, so a report line opens at the entry. */
|
|
42
|
+
readonly line: number
|
|
43
|
+
readonly claim: ClaimKind
|
|
44
|
+
/** The digit-spelled figures behind a `countable` reading, in order. */
|
|
45
|
+
readonly figures: readonly string[]
|
|
46
|
+
/** The first sentence behind an `invariant` reading, absent otherwise. */
|
|
47
|
+
readonly quantified?: string
|
|
48
|
+
/**
|
|
49
|
+
* Executable checks the entry names, which is the only coverage signal the
|
|
50
|
+
* record carries. Empty on an entry that names none, including one whose
|
|
51
|
+
* claim some check happens to cover without the entry saying so.
|
|
52
|
+
*/
|
|
53
|
+
readonly checks: readonly string[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ArchitectureReport {
|
|
57
|
+
readonly rel: string
|
|
58
|
+
readonly lines: number
|
|
59
|
+
/** What the record declared, absent when it states no length rule. */
|
|
60
|
+
readonly allowances?: Allowances
|
|
61
|
+
/** The frame plus the per-decision allowance, absent alongside it. */
|
|
62
|
+
readonly ceiling?: number
|
|
63
|
+
readonly decisions: readonly DecisionReport[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const DECISION_HEADING = /^###\s+(.+?)\s*$/
|
|
67
|
+
const SECTION_HEADING = /^##\s+\S/
|
|
68
|
+
const CODE_SPAN = /`[^`]*`/g
|
|
69
|
+
/** Dropped ahead of the figure scan, since an anchor date is not a claim. */
|
|
70
|
+
const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/g
|
|
71
|
+
const FIGURE = /\b\d+(?:,\d{3})*\b/g
|
|
72
|
+
const SENTENCE_SPLIT = /(?<=[.])\s+/
|
|
73
|
+
|
|
74
|
+
/** A code span naming a path, which is what a quantifier has to govern. */
|
|
75
|
+
const PATH_SPAN = String.raw`\`[^\`]*(?:/|\.md|\.ts|\.sh|\.json)[^\`]*\``
|
|
76
|
+
const QUANTIFIER = String.raw`\b(?:every|each|no|nothing|any|all|never|only)\b`
|
|
77
|
+
/** Six words is the widest gap the corpus puts between the two. */
|
|
78
|
+
const WINDOW = String.raw`(?:\s+\S+){0,6}?\s+`
|
|
79
|
+
|
|
80
|
+
const QUANTIFIES_PATH = new RegExp(
|
|
81
|
+
`${QUANTIFIER}${WINDOW}${PATH_SPAN}|${PATH_SPAN}${WINDOW}${QUANTIFIER}`,
|
|
82
|
+
'i',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
/** A code span naming a shell check this repository could run. */
|
|
86
|
+
const SCRIPT_SPAN = /^scripts\/[\w./-]+\.sh$/
|
|
87
|
+
/** A code span invoking the CLI, which may or may not name a registered audit. */
|
|
88
|
+
const AITK_SPAN = /^aitk\s+(.+)$/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The audit invocations a decision could name, spelled as a reader writes them.
|
|
92
|
+
*
|
|
93
|
+
* Read off the catalog rather than listed, so a verb renamed at its source
|
|
94
|
+
* stops matching here instead of going on matching a string nobody maintains.
|
|
95
|
+
*/
|
|
96
|
+
const AUDIT_INVOCATIONS: readonly string[] = AUDITS.map((audit) =>
|
|
97
|
+
audit.argv.filter((arg) => arg !== '--json').join(' '),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
function codeSpans(text: string): string[] {
|
|
101
|
+
return (text.match(CODE_SPAN) ?? []).map((span) => span.slice(1, -1))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Names the executable checks an entry spells.
|
|
106
|
+
*
|
|
107
|
+
* A script has to exist on disk and an `aitk` invocation has to match a
|
|
108
|
+
* registered audit, because an entry naming a check that was removed is an
|
|
109
|
+
* uncovered claim wearing a covered one's words.
|
|
110
|
+
*/
|
|
111
|
+
async function namedChecks(root: string, body: string): Promise<string[]> {
|
|
112
|
+
const spans = [...new Set(codeSpans(body))]
|
|
113
|
+
|
|
114
|
+
const verbs = spans.filter((span) => {
|
|
115
|
+
const invocation = span.match(AITK_SPAN)?.[1]
|
|
116
|
+
return invocation !== undefined && AUDIT_INVOCATIONS.includes(invocation)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const scripts = spans.filter((span) => SCRIPT_SPAN.test(span))
|
|
120
|
+
const present = await Promise.all(
|
|
121
|
+
scripts.map((span) =>
|
|
122
|
+
access(join(root, span)).then(
|
|
123
|
+
() => true,
|
|
124
|
+
() => false,
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return [...verbs, ...scripts.filter((_, index) => present[index])]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Reads the figures a decision carries, which is the countable-claim signal.
|
|
134
|
+
*
|
|
135
|
+
* Digits alone. A cardinal spelled in words reads as pronominal far more often
|
|
136
|
+
* than as measured in this corpus, where "the alternative and it is one nobody
|
|
137
|
+
* passes" outnumbers "eleven copies", and admitting the spelled form classified
|
|
138
|
+
* 22 of 24 entries as countable, which distinguishes nothing. The cost is that
|
|
139
|
+
* a measured claim written in words reads as uncounted, which the report says.
|
|
140
|
+
*/
|
|
141
|
+
function figuresIn(body: string): string[] {
|
|
142
|
+
return body.replace(ISO_DATE, ' ').replace(CODE_SPAN, ' ').match(FIGURE) ?? []
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The first sentence quantifying over a named tree, or undefined.
|
|
147
|
+
*
|
|
148
|
+
* The quantifier has to sit within a short window of the path so a sentence
|
|
149
|
+
* mentioning both without relating them does not read as a claim about the
|
|
150
|
+
* tree. Both orders are matched, since the corpus writes the property before
|
|
151
|
+
* the path as readily as after it.
|
|
152
|
+
*/
|
|
153
|
+
function quantifiedSentence(body: string): string | undefined {
|
|
154
|
+
return body
|
|
155
|
+
.replace(ISO_DATE, ' ')
|
|
156
|
+
.split(SENTENCE_SPLIT)
|
|
157
|
+
.map((sentence) => sentence.replace(/\n/g, ' ').trim())
|
|
158
|
+
.find((sentence) => QUANTIFIES_PATH.test(sentence))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Classifies one entry, with the countable reading taking precedence.
|
|
163
|
+
*
|
|
164
|
+
* An entry carrying both a figure and a quantified tree is recomputable, which
|
|
165
|
+
* is the stronger test, and the two readings are reported as one kind because
|
|
166
|
+
* the coverage split a reader wants is testable against unverifiable rather
|
|
167
|
+
* than a per-entry inventory of every claim in it.
|
|
168
|
+
*/
|
|
169
|
+
export function classifyDecision(body: string): {
|
|
170
|
+
claim: ClaimKind
|
|
171
|
+
figures: string[]
|
|
172
|
+
quantified?: string
|
|
173
|
+
} {
|
|
174
|
+
const figures = figuresIn(body)
|
|
175
|
+
if (figures.length > 0) return { claim: 'countable', figures }
|
|
176
|
+
|
|
177
|
+
const quantified = quantifiedSentence(body)
|
|
178
|
+
if (quantified !== undefined) {
|
|
179
|
+
return { claim: 'invariant', figures: [], quantified }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { claim: 'neither', figures: [] }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
interface RawDecision {
|
|
186
|
+
readonly heading: string
|
|
187
|
+
readonly line: number
|
|
188
|
+
readonly body: string
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Splits the record into its `###` entries.
|
|
193
|
+
*
|
|
194
|
+
* A heading inside a fenced block is skipped, since the seed template shows the
|
|
195
|
+
* shape it asks a project to write and a template entry is not a decision.
|
|
196
|
+
*
|
|
197
|
+
* A heading carrying several decisions counts once, so the total reads low by
|
|
198
|
+
* however many it holds. The report states that rather than parsing for it,
|
|
199
|
+
* because splitting a decision from its heading needs a marker the standard
|
|
200
|
+
* does not ask a record to carry.
|
|
201
|
+
*/
|
|
202
|
+
export function splitDecisions(source: string): RawDecision[] {
|
|
203
|
+
const lines = bodyLines(source)
|
|
204
|
+
const decisions: RawDecision[] = []
|
|
205
|
+
let open: { heading: string; line: number; body: string[] } | undefined
|
|
206
|
+
|
|
207
|
+
for (const line of lines) {
|
|
208
|
+
if (line.fenced) {
|
|
209
|
+
open?.body.push(line.text)
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const heading = line.text.match(DECISION_HEADING)?.[1]
|
|
214
|
+
if (heading !== undefined) {
|
|
215
|
+
if (open) decisions.push({ ...open, body: open.body.join('\n') })
|
|
216
|
+
open = { heading, line: line.number, body: [] }
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A decision runs to the next `###` or to the section that follows the
|
|
221
|
+
// decision list, so the risks below never read as the last entry's body.
|
|
222
|
+
if (SECTION_HEADING.test(line.text)) {
|
|
223
|
+
if (open) decisions.push({ ...open, body: open.body.join('\n') })
|
|
224
|
+
open = undefined
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
open?.body.push(line.text)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (open) decisions.push({ ...open, body: open.body.join('\n') })
|
|
232
|
+
return decisions
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Cardinals a record spells rather than writes, which the corpus does for both. */
|
|
236
|
+
const SPELLED: Record<string, number> = {
|
|
237
|
+
one: 1,
|
|
238
|
+
two: 2,
|
|
239
|
+
three: 3,
|
|
240
|
+
four: 4,
|
|
241
|
+
five: 5,
|
|
242
|
+
six: 6,
|
|
243
|
+
seven: 7,
|
|
244
|
+
eight: 8,
|
|
245
|
+
nine: 9,
|
|
246
|
+
ten: 10,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const CARDINAL = String.raw`(\d+|${Object.keys(SPELLED).join('|')})`
|
|
250
|
+
const FRAME_CLAUSE = new RegExp(String.raw`${CARDINAL}-line frame`, 'i')
|
|
251
|
+
const PER_DECISION_CLAUSE = new RegExp(
|
|
252
|
+
String.raw`${CARDINAL}\s+lines?\s+a\s+decision`,
|
|
253
|
+
'i',
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
function readCardinal(token: string | undefined): number | undefined {
|
|
257
|
+
if (token === undefined) return undefined
|
|
258
|
+
const spelled = SPELLED[token.toLowerCase()]
|
|
259
|
+
if (spelled !== undefined) return spelled
|
|
260
|
+
|
|
261
|
+
const digits = Number.parseInt(token, 10)
|
|
262
|
+
return Number.isNaN(digits) ? undefined : digits
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reads the allowances a record declares for itself, or nothing.
|
|
267
|
+
*
|
|
268
|
+
* Both clauses have to be present, because half a formula is not one. A record
|
|
269
|
+
* whose wording drifts past these clauses falls back to reporting rather than
|
|
270
|
+
* to a stale ceiling held here, so the failure is visible in the run's own
|
|
271
|
+
* output instead of gating a project on a rule nobody can point at.
|
|
272
|
+
*/
|
|
273
|
+
export function readAllowances(source: string): Allowances | undefined {
|
|
274
|
+
const frame = readCardinal(source.match(FRAME_CLAUSE)?.[1])
|
|
275
|
+
const perDecision = readCardinal(source.match(PER_DECISION_CLAUSE)?.[1])
|
|
276
|
+
|
|
277
|
+
if (frame === undefined || perDecision === undefined) return undefined
|
|
278
|
+
return { frame, perDecision }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Whether a read failed because nothing sits at the path. */
|
|
282
|
+
function isMissing(error: unknown): boolean {
|
|
283
|
+
const code = (error as { code?: unknown }).code
|
|
284
|
+
return code === 'ENOENT' || code === 'ENOTDIR'
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The ceiling the record's own formula derives from its decision count. */
|
|
288
|
+
export function ceilingFor(allowances: Allowances, decisions: number): number {
|
|
289
|
+
return allowances.frame + allowances.perDecision * decisions
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Measures the record, or reports nothing when the project carries none.
|
|
294
|
+
*
|
|
295
|
+
* Absent rather than empty, for the reason the sibling checks state: a project
|
|
296
|
+
* with no record and one whose record holds no decision are different answers,
|
|
297
|
+
* and a zeroed report reads as the second.
|
|
298
|
+
*/
|
|
299
|
+
export async function measureArchitecture(
|
|
300
|
+
root: string,
|
|
301
|
+
): Promise<ArchitectureReport | undefined> {
|
|
302
|
+
const path = join(root, RECORD_REL)
|
|
303
|
+
|
|
304
|
+
let source: string
|
|
305
|
+
try {
|
|
306
|
+
source = await readFile(path, 'utf8')
|
|
307
|
+
} catch (error) {
|
|
308
|
+
// Only a record that is not there reads as absent. A record present and
|
|
309
|
+
// unreadable propagates the way every sibling reader here lets one
|
|
310
|
+
// propagate, since swallowing it reports a project with no record and the
|
|
311
|
+
// length gate passes over a file nobody opened.
|
|
312
|
+
if (!isMissing(error)) throw error
|
|
313
|
+
return undefined
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const allowances = readAllowances(source)
|
|
317
|
+
const raw = splitDecisions(source)
|
|
318
|
+
const decisions = await Promise.all(
|
|
319
|
+
raw.map(async (entry) => {
|
|
320
|
+
const { claim, figures, quantified } = classifyDecision(entry.body)
|
|
321
|
+
return {
|
|
322
|
+
heading: entry.heading,
|
|
323
|
+
line: entry.line,
|
|
324
|
+
claim,
|
|
325
|
+
figures,
|
|
326
|
+
...(quantified !== undefined && { quantified }),
|
|
327
|
+
checks: await namedChecks(root, entry.body),
|
|
328
|
+
}
|
|
329
|
+
}),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
rel: RECORD_REL,
|
|
334
|
+
lines: source.replace(/\n$/, '').split('\n').length,
|
|
335
|
+
...(allowances !== undefined && {
|
|
336
|
+
allowances,
|
|
337
|
+
ceiling: ceilingFor(allowances, raw.length),
|
|
338
|
+
}),
|
|
339
|
+
decisions,
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Whether the record is longer than the ceiling it derives for itself.
|
|
345
|
+
*
|
|
346
|
+
* False for a record declaring no allowances, which has no ceiling to be past.
|
|
347
|
+
* That is the answer rather than a gap, since the length rule is the record's
|
|
348
|
+
* own and a project that never wrote one owes nothing to it.
|
|
349
|
+
*/
|
|
350
|
+
export function isOverLength(report: ArchitectureReport): boolean {
|
|
351
|
+
return report.ceiling !== undefined && report.lines > report.ceiling
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** How many entries carry a claim a machine could test. */
|
|
355
|
+
export function testableCount(report: ArchitectureReport): number {
|
|
356
|
+
return report.decisions.filter((entry) => entry.claim !== 'neither').length
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** How many testable entries name a check that exists. */
|
|
360
|
+
export function coveredCount(report: ArchitectureReport): number {
|
|
361
|
+
return report.decisions.filter(
|
|
362
|
+
(entry) => entry.claim !== 'neither' && entry.checks.length > 0,
|
|
363
|
+
).length
|
|
364
|
+
}
|
package/src/context/gate.ts
CHANGED
|
@@ -4,6 +4,15 @@ import type { FolderDrift } from '@/context/index-drift'
|
|
|
4
4
|
export interface GateInput {
|
|
5
5
|
/** Cited paths that resolved to nothing, which gate under either mode. */
|
|
6
6
|
readonly unresolvedCitations: number
|
|
7
|
+
/**
|
|
8
|
+
* Whether the architecture record is longer than the ceiling it derives for
|
|
9
|
+
* itself, which gates under either mode for the reason a citation does.
|
|
10
|
+
*
|
|
11
|
+
* False when the project carries no record and false under
|
|
12
|
+
* `--citations-only`, which never measures it. That mode runs one check by
|
|
13
|
+
* construction, so widening it here would gate on a reading it never took.
|
|
14
|
+
*/
|
|
15
|
+
readonly recordOverLength: boolean
|
|
7
16
|
readonly sections: readonly SectionFinding[]
|
|
8
17
|
readonly drift: readonly FolderDrift[]
|
|
9
18
|
/**
|
|
@@ -24,19 +33,24 @@ export function hasDrift(drift: readonly FolderDrift[]): boolean {
|
|
|
24
33
|
/**
|
|
25
34
|
* Whether the audit found something that should fail the caller.
|
|
26
35
|
*
|
|
27
|
-
* An unresolved citation is a broken pointer and gates unconditionally
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
36
|
+
* An unresolved citation is a broken pointer and gates unconditionally, and so
|
|
37
|
+
* does a record past its own ceiling: the record states the limit for itself
|
|
38
|
+
* 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 two findings `--gate` adds
|
|
40
|
+
* are the ones answerable from the file itself: a required section it does not
|
|
41
|
+
* declare, and an index disagreeing with its folder. Entry length, depth,
|
|
42
|
+
* bullet, table, provenance, and the record's claim coverage are judgments, so
|
|
43
|
+
* they stay out under both modes.
|
|
32
44
|
*/
|
|
33
45
|
export function isGating({
|
|
34
46
|
unresolvedCitations,
|
|
47
|
+
recordOverLength,
|
|
35
48
|
sections,
|
|
36
49
|
drift,
|
|
37
50
|
widened,
|
|
38
51
|
}: GateInput): boolean {
|
|
39
52
|
if (unresolvedCitations > 0) return true
|
|
53
|
+
if (recordOverLength) return true
|
|
40
54
|
if (!widened) return false
|
|
41
55
|
|
|
42
56
|
return sections.length > 0 || hasDrift(drift)
|
package/src/design/parse.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* One table cell, split into the value a renderer emits and whether the record
|
|
5
|
+
* marked it as unsourced.
|
|
6
|
+
*
|
|
7
|
+
* The marker sits inside the cell rather than in a trailing column, because a
|
|
8
|
+
* trailing marker breaks the table parse. Splitting it out here is what keeps a
|
|
9
|
+
* swatch or a font sample built from the value alone.
|
|
10
|
+
*/
|
|
11
|
+
export interface Cell {
|
|
12
|
+
value: string
|
|
13
|
+
tagged: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Row = Record<string, Cell>
|
|
4
17
|
|
|
5
18
|
export interface DesignDoc {
|
|
6
19
|
personality: string
|
|
@@ -12,6 +25,11 @@ export interface DesignDoc {
|
|
|
12
25
|
iconography: string
|
|
13
26
|
}
|
|
14
27
|
|
|
28
|
+
const VERIFY_TAG = /\s*\?\s*verify\s*$/
|
|
29
|
+
|
|
30
|
+
/** A cell whose whole content is one balanced code span and nothing else. */
|
|
31
|
+
const CODE_SPAN = /^`([^`]*)`$/
|
|
32
|
+
|
|
15
33
|
export function parseDesignDoc(path: string): DesignDoc {
|
|
16
34
|
const raw = readFileSync(path, 'utf8')
|
|
17
35
|
const sections = splitSections(raw)
|
|
@@ -58,22 +76,41 @@ function table(body: string | undefined): Row[] {
|
|
|
58
76
|
if (!body) return []
|
|
59
77
|
const rows = body.split('\n').filter((l) => l.trim().startsWith('|'))
|
|
60
78
|
if (rows.length < 2) return []
|
|
61
|
-
const headers = splitRow(rows[0])
|
|
79
|
+
const headers = splitRow(rows[0]).map((c) => c.value)
|
|
62
80
|
const data = rows.slice(2)
|
|
63
81
|
return data.map((line) => {
|
|
64
82
|
const cells = splitRow(line)
|
|
65
83
|
const row: Row = {}
|
|
66
84
|
headers.forEach((h, i) => {
|
|
67
|
-
row[h] =
|
|
85
|
+
row[h] = cells[i] ?? emptyCell()
|
|
68
86
|
})
|
|
69
87
|
return row
|
|
70
88
|
})
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
function
|
|
91
|
+
function emptyCell(): Cell {
|
|
92
|
+
return { value: '', tagged: false }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitRow(line: string): Cell[] {
|
|
74
96
|
return line
|
|
75
97
|
.replace(/^\s*\|/, '')
|
|
76
98
|
.replace(/\|\s*$/, '')
|
|
77
99
|
.split('|')
|
|
78
|
-
.map(
|
|
100
|
+
.map(parseCell)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The tag is tested against the cell with any surrounding code span removed,
|
|
105
|
+
* since a value wrapping itself in backticks puts one after the tag and an
|
|
106
|
+
* end-anchored test misses it there. The span is restored around the clean
|
|
107
|
+
* value so an untagged cell and a tagged one carry the same formatting.
|
|
108
|
+
*/
|
|
109
|
+
function parseCell(raw: string): Cell {
|
|
110
|
+
const trimmed = raw.trim()
|
|
111
|
+
const span = trimmed.match(CODE_SPAN)
|
|
112
|
+
const inner = span ? span[1] : trimmed
|
|
113
|
+
if (!VERIFY_TAG.test(inner)) return { value: trimmed, tagged: false }
|
|
114
|
+
const value = inner.replace(VERIFY_TAG, '')
|
|
115
|
+
return { value: span ? `\`${value}\`` : value, tagged: true }
|
|
79
116
|
}
|