division-ds 0.0.0-stage → 0.1.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.
@@ -0,0 +1,320 @@
1
+ // The props validator: every JSX element that is a Division DS component, checked against the
2
+ // component manifest (public/manifest/components.json). Run in a consumer project through
3
+ // `division-ds check` (scripts/division-ds.mjs); this file is the pure function it wraps, the
4
+ // same split the other gates use, so the rules are unit-testable without a filesystem.
5
+ //
6
+ // Why it exists. Inside this repository `tsc` catches a wrong prop. An agent writing a screen in
7
+ // another project often has no type-check in its loop (a JS project, a sandbox, a file it never
8
+ // compiles), and the two mistakes it makes are the ones a type would catch: a prop the component
9
+ // does not take (`<Badge variant=...>`, where Badge's prop is `tone`), and an enum value that is
10
+ // not in the union (`<Button variant="danger">`, where the value is `hostile`). Both render
11
+ // something, so neither is visible until a person looks.
12
+ //
13
+ // Parsing is the TypeScript compiler's own (`ts.createSourceFile`, syntax only: no program, no
14
+ // type-check, no tsconfig), so generics, arrow functions in attributes and fragments are read
15
+ // exactly as the compiler reads them. A regex tag scanner like button-spacing.mjs's is enough
16
+ // for a parent/child count; it is not enough to know which import a tag name refers to.
17
+ //
18
+ // What the manifest can and cannot say, and so what is checked:
19
+ // - A props table lists the props declared in this repository, plus a `...rest` row when the
20
+ // component spreads native or Radix props. It never lists every inherited native attribute.
21
+ // - Enum values: checked on every component and part, wherever a row's type is a union of string
22
+ // literals and the value is a literal.
23
+ // - Unknown props: checked only on a component that is the one component its manifest entry
24
+ // exports. A compound entry (Dialog, Tabs, DropdownMenu, Card, ...) lists each part's own props
25
+ // and nothing of the Radix part underneath, so judging `<TabsTrigger asChild>` against the table
26
+ // would be a false positive. A false positive teaches an agent to stop reading the gate.
27
+ // - With a `...rest` row of native attributes, an event handler or a standard HTML attribute is
28
+ // accepted; one the rest type names in its Omit<> is not. With a `...rest` row of a Radix
29
+ // component's props, any other prop is accepted (the Radix surface is not in the manifest).
30
+ import ts from 'typescript'
31
+ import { parseRowName, resolvePart } from './component-props.mjs'
32
+
33
+ /** Always accepted on any component: React's own props, and attributes every DOM wrapper takes. */
34
+ const ALWAYS = new Set(['key', 'ref', 'className', 'children', 'id', 'style'])
35
+ const isAlwaysAllowed = name => ALWAYS.has(name) || /^(aria|data)-/.test(name)
36
+ const isEventHandler = name => /^on[A-Z]/.test(name)
37
+
38
+ /** The HTML and React DOM attributes a native `...rest` accepts, beyond aria-*, data-* and on*.
39
+ * Generous on purpose: a missing name here is a false positive, an extra one only a miss. */
40
+ const HTML_ATTRIBUTES = new Set(
41
+ (
42
+ 'about accessKey autoCapitalize autoCorrect autoFocus autoSave contentEditable contextMenu dir draggable ' +
43
+ 'enterKeyHint hidden inert inputMode is itemID itemProp itemRef itemScope itemType lang nonce part popover ' +
44
+ 'popoverTarget popoverTargetAction prefix property radioGroup resource rel rev role security slot spellCheck ' +
45
+ 'suppressContentEditableWarning suppressHydrationWarning tabIndex title translate typeof unselectable vocab ' +
46
+ 'dangerouslySetInnerHTML defaultChecked defaultValue color results ' +
47
+ // forms, buttons, inputs, textareas
48
+ 'accept alt autoComplete capture checked cols dirName disabled form formAction formEncType formMethod ' +
49
+ 'formNoValidate formTarget height list max maxLength min minLength multiple name pattern placeholder ' +
50
+ 'readOnly required rows size src step type value width wrap ' +
51
+ // anchors
52
+ 'download href hrefLang media ping referrerPolicy target'
53
+ ).split(/\s+/)
54
+ )
55
+
56
+ /** Words that mean the same thing to an agent. A value in a group that is not in the union gets
57
+ * the first member of its group that is: Button's `variant="hostile"` becomes "danger", and
58
+ * Badge's `tone="danger"` becomes "hostile" (the system uses both words, on different props). */
59
+ const SYNONYMS = [
60
+ ['hostile', 'danger', 'destructive', 'error', 'negative', 'critical', 'red'],
61
+ ['ok', 'success', 'positive', 'green'],
62
+ ['warning', 'warn', 'caution', 'amber', 'yellow'],
63
+ ['info', 'information', 'blue'],
64
+ ['secondary', 'outline', 'outlined'],
65
+ ['ghost', 'text', 'plain', 'link'],
66
+ ['quiet', 'muted', 'subtle'],
67
+ ['sm', 'small', 'xs'],
68
+ ['md', 'medium', 'default'],
69
+ ['lg', 'large', 'xl'],
70
+ ]
71
+
72
+ /** The union member an agent most likely meant by `value`, or undefined. */
73
+ export function synonymIn(value, literals) {
74
+ const group = SYNONYMS.find(words => words.includes(value.toLowerCase()))
75
+ return group?.find(word => literals.includes(word))
76
+ }
77
+
78
+ /** The string literals of a type cell, or null when the type is not purely a union of them
79
+ * (`'sm' | 'md'` -> ['sm', 'md']; `string`, `number | 'auto'`, `ReactNode` -> null).
80
+ * `undefined` members are ignored: an optional union is still the same union. */
81
+ export function literalUnion(type) {
82
+ if (!type) return null
83
+ const members = type.split('|').map(member => member.trim()).filter(member => member && member !== 'undefined')
84
+ if (members.length === 0) return null
85
+ const values = []
86
+ for (const member of members) {
87
+ const match = member.match(/^'([^']*)'$/) || member.match(/^"([^"]*)"$/)
88
+ if (!match) return null
89
+ values.push(match[1])
90
+ }
91
+ return values
92
+ }
93
+
94
+ /** What a `...rest` row's type says: `{ open, excluded }`. `open` is 'native' for HTML attributes
95
+ * (`HTMLAttributes<...>`, `ButtonHTMLAttributes<...>`), 'any' for anything else (a Radix part's
96
+ * props: the manifest cannot list them). `excluded` is the Omit<> list, plus every aria-* key
97
+ * when the type says it removes them (Select). */
98
+ export function parseRest(type = '') {
99
+ const excluded = new Set()
100
+ const omit = type.match(/Omit<[\s\S]*?,\s*((?:'[^']*'\s*\|?\s*)+)>/)
101
+ if (omit) for (const match of omit[1].matchAll(/'([^']*)'/g)) excluded.add(match[1])
102
+ // `children` is left out even when omitted: a component that renders children declares its own
103
+ // `children` prop and Omits the native one, and the tables do not list `children` rows reliably
104
+ // (meta.test.ts exempts it from coverage), so the manifest cannot tell the two cases apart.
105
+ excluded.delete('children')
106
+ if (/\bminus\b[^.]*\bdata-testid\b/.test(type)) excluded.add('data-testid')
107
+ const minusAria = /\bminus every aria-\* key/.test(type)
108
+ const native = /\b\w*HTMLAttributes</.test(type) || /^HTMLAttributes/.test(type)
109
+ return { open: native ? 'native' : 'any', excluded, minusAria }
110
+ }
111
+
112
+ const isComponentExport = name => /^[A-Z][a-z]/.test(name) && !/Variants$/.test(name)
113
+
114
+ /**
115
+ * The manifest, indexed by export name: `Map<exportName, spec>` where a spec is
116
+ * `{ entry, docs, props: Map<prop, { type, literals }>, rest, checkUnknown }`.
117
+ * Rows are attributed to parts the way `checkPropsTable` attributes them (component-props.mjs):
118
+ * a `(Part)` qualifier names one export; an unqualified row belongs to every component the
119
+ * entry exports.
120
+ */
121
+ export function buildIndex(manifest) {
122
+ const index = new Map()
123
+ for (const entry of manifest.components ?? []) {
124
+ const exportNames = (entry.imports ?? []).flatMap(item => item.names).filter(isComponentExport)
125
+ if (exportNames.length === 0) continue
126
+ const components = Object.fromEntries(exportNames.map(name => [name, true]))
127
+ const specs = Object.fromEntries(
128
+ exportNames.map(name => [name, { entry: entry.name, docs: entry.docs, props: new Map(), rest: null }])
129
+ )
130
+ let unresolved = false
131
+ for (const row of entry.props ?? []) {
132
+ const parsed = parseRowName(row.name)
133
+ if (parsed.call) continue
134
+ let holders = exportNames
135
+ // A rest row names its part in the type cell: `(Radio) Omit<RadioGroup.Item props, ...>`.
136
+ const part = parsed.rest ? row.type.match(/^\(([^)]+)\)\s/)?.[1] : parsed.part
137
+ if (part) {
138
+ const target = resolvePart(part, entry.name, { components, types: {} })
139
+ if (!target) {
140
+ unresolved = true
141
+ continue
142
+ }
143
+ holders = [target.name]
144
+ }
145
+ if (parsed.rest) {
146
+ const rest = parseRest(row.type.replace(/^\([^)]+\)\s*/, ''))
147
+ for (const name of holders) specs[name].rest = rest
148
+ continue
149
+ }
150
+ if (row.type === '—') continue
151
+ for (const name of holders) {
152
+ for (const prop of parsed.props) specs[name].props.set(prop, { type: row.type, literals: literalUnion(row.type) })
153
+ }
154
+ }
155
+ const single = exportNames.length === 1
156
+ for (const name of exportNames) {
157
+ const spec = specs[name]
158
+ spec.checkUnknown = single && !unresolved && spec.rest?.open !== 'any'
159
+ index.set(name, spec)
160
+ }
161
+ }
162
+ return index
163
+ }
164
+
165
+ /** True when an import specifier is Division DS: a component path (`components/atoms|molecules|
166
+ * organisms`, any prefix) or the package name. Anything else is the consumer's own code. */
167
+ export function isDivisionSource(specifier) {
168
+ return /(^|\/)components\/(atoms|molecules|organisms)(\/|$)/.test(specifier) || /^division-ds(\/|$)/.test(specifier)
169
+ }
170
+
171
+ export function distance(a, b) {
172
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
173
+ for (let i = 1; i <= a.length; i++) {
174
+ let previous = row[0]
175
+ row[0] = i
176
+ for (let j = 1; j <= b.length; j++) {
177
+ const current = row[j]
178
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1))
179
+ previous = current
180
+ }
181
+ }
182
+ return row[b.length]
183
+ }
184
+
185
+ /** The nearest candidate to `word`, or undefined when nothing is close enough to be a typo. */
186
+ export function closest(word, candidates) {
187
+ let best
188
+ let bestScore = Infinity
189
+ for (const candidate of candidates) {
190
+ const score = distance(word, candidate)
191
+ if (score < bestScore) {
192
+ best = candidate
193
+ bestScore = score
194
+ }
195
+ }
196
+ return bestScore <= Math.max(2, Math.floor(word.length / 3)) ? best : undefined
197
+ }
198
+
199
+ const list = values => values.map(value => `"${value}"`).join(', ')
200
+
201
+ /** The literal string values an attribute's initializer can take, or null when it is not a
202
+ * literal (a variable, a call, a template with substitutions). A conditional of two literals
203
+ * yields both. */
204
+ function literalValues(initializer) {
205
+ if (!initializer) return null
206
+ if (ts.isStringLiteral(initializer)) return [initializer]
207
+ if (!ts.isJsxExpression(initializer) || !initializer.expression) return null
208
+ const collect = node => {
209
+ while (ts.isParenthesizedExpression(node)) node = node.expression
210
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node]
211
+ if (ts.isConditionalExpression(node)) {
212
+ const whenTrue = collect(node.whenTrue)
213
+ const whenFalse = collect(node.whenFalse)
214
+ return whenTrue && whenFalse ? [...whenTrue, ...whenFalse] : null
215
+ }
216
+ return null
217
+ }
218
+ return collect(initializer.expression)
219
+ }
220
+
221
+ function scriptKind(path) {
222
+ if (/\.tsx$/.test(path)) return ts.ScriptKind.TSX
223
+ if (/\.jsx$/.test(path)) return ts.ScriptKind.JSX
224
+ if (/\.[cm]?js$/.test(path)) return ts.ScriptKind.JSX
225
+ return ts.ScriptKind.TS
226
+ }
227
+
228
+ /**
229
+ * Every props violation in one file: `{ path, line, column, rule, component, prop, found, fix }`.
230
+ * `rule` is 'unknown-prop' or 'invalid-value'. `index` comes from `buildIndex`.
231
+ */
232
+ export function findPropViolations(text, path, index) {
233
+ const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, scriptKind(path))
234
+ // local name -> export name, for named imports from Division DS; namespace imports by local name.
235
+ const named = new Map()
236
+ const namespaces = new Set()
237
+ for (const statement of source.statements) {
238
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue
239
+ if (!isDivisionSource(statement.moduleSpecifier.text)) continue
240
+ const bindings = statement.importClause?.namedBindings
241
+ if (!bindings) continue
242
+ if (ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text)
243
+ else for (const element of bindings.elements) named.set(element.name.text, (element.propertyName ?? element.name).text)
244
+ }
245
+ if (named.size === 0 && namespaces.size === 0) return []
246
+
247
+ const violations = []
248
+ const resolve = tagName => {
249
+ if (ts.isIdentifier(tagName)) return named.get(tagName.text)
250
+ if (ts.isPropertyAccessExpression(tagName) && ts.isIdentifier(tagName.expression) && namespaces.has(tagName.expression.text)) {
251
+ return tagName.name.text
252
+ }
253
+ return undefined
254
+ }
255
+
256
+ const check = element => {
257
+ const exportName = resolve(element.tagName)
258
+ const spec = exportName && index.get(exportName)
259
+ if (!spec) return
260
+ const tag = element.tagName.getText(source)
261
+ const known = [...spec.props.keys()]
262
+ for (const attribute of element.attributes.properties) {
263
+ if (!ts.isJsxAttribute(attribute)) continue
264
+ const prop = attribute.name.getText(source)
265
+ const at = source.getLineAndCharacterOfPosition(attribute.getStart(source))
266
+ const add = (rule, found, fix) =>
267
+ violations.push({ path, line: at.line + 1, column: at.character + 1, rule, component: tag, prop, found, fix })
268
+ const row = spec.props.get(prop)
269
+
270
+ if (!row) {
271
+ const rest = spec.rest
272
+ const excluded = rest && (rest.excluded.has(prop) || (rest.minusAria && /^aria-/.test(prop)))
273
+ if (excluded) {
274
+ add('unknown-prop', `<${tag} ${prop}>`, `${spec.entry} types "${prop}" away on purpose. ${known.length ? `Its props: ${known.join(', ')}.` : ''} See ${spec.docs}.`.replace(/\s+/g, ' ').trim())
275
+ continue
276
+ }
277
+ if (!spec.checkUnknown || isAlwaysAllowed(prop)) continue
278
+ if (rest && (isEventHandler(prop) || HTML_ATTRIBUTES.has(prop))) continue
279
+ const near = closest(prop, known)
280
+ const hint = near
281
+ ? `Did you mean "${near}"?`
282
+ : known.length
283
+ ? `${tag} takes: ${known.join(', ')}${rest ? ', and native attributes' : ''}.`
284
+ : `${tag} takes no props of its own.`
285
+ add('unknown-prop', `<${tag} ${prop}>`, `${tag} has no prop "${prop}". ${hint} See ${spec.docs}.`)
286
+ continue
287
+ }
288
+
289
+ if (!row.literals) continue
290
+ const values = literalValues(attribute.initializer)
291
+ if (!values) continue
292
+ for (const value of values) {
293
+ if (row.literals.includes(value.text)) continue
294
+ const near = synonymIn(value.text, row.literals) ?? closest(value.text, row.literals)
295
+ const valueAt = source.getLineAndCharacterOfPosition(value.getStart(source))
296
+ violations.push({
297
+ path,
298
+ line: valueAt.line + 1,
299
+ column: valueAt.character + 1,
300
+ rule: 'invalid-value',
301
+ component: tag,
302
+ prop,
303
+ found: `${prop}="${value.text}"`,
304
+ fix: `${tag}'s ${prop} is one of ${list(row.literals)}.${near ? ` Write ${prop}="${near}".` : ''}`,
305
+ })
306
+ }
307
+ }
308
+ }
309
+
310
+ const visit = node => {
311
+ if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) check(node)
312
+ ts.forEachChild(node, visit)
313
+ }
314
+ visit(source)
315
+ return violations.sort((a, b) => a.line - b.line || a.column - b.column)
316
+ }
317
+
318
+ export function formatPropViolation({ path, line, column, rule, found, fix }) {
319
+ return `${path}:${line}:${column} [${rule}] ${found}\n ${fix}`
320
+ }
@@ -0,0 +1,132 @@
1
+ // The rules installer: writes the Rules section of this repository's AGENTS.md into a consumer
2
+ // project, for the three agents that read a file by convention. Run through
3
+ // `division-ds rules` (scripts/division-ds.mjs); this file plans every change as data, so the
4
+ // runner's dry run and its real run are the same code, and the tests need only a temp dir.
5
+ //
6
+ // Why markers and not a copy. The rules change when an agent gets something new wrong; a copy
7
+ // pasted into another project goes stale the day it is pasted. Between namespaced markers the
8
+ // block is replaced on every run (idempotent: a second run with the same rules changes nothing)
9
+ // and the consumer's own text around it is never touched. `--remove` takes the block back out.
10
+ //
11
+ // Targets:
12
+ // agents AGENTS.md Codex, Claude Code (via AGENTS.md), Copilot, Jules, ...
13
+ // cursor .cursor/rules/division-ds.mdc Cursor project rules; the whole file is ours
14
+ // gemini GEMINI.md Gemini CLI
15
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { dirname, join } from 'node:path'
17
+
18
+ export const START = '<!-- division-ds:start -->'
19
+ export const END = '<!-- division-ds:end -->'
20
+ export const TARGETS = ['agents', 'cursor', 'gemini']
21
+
22
+ const FILES = {
23
+ agents: 'AGENTS.md',
24
+ cursor: '.cursor/rules/division-ds.mdc',
25
+ gemini: 'GEMINI.md',
26
+ }
27
+
28
+ const NOTE =
29
+ '> Installed by `division-ds rules`. Re-run it to update; anything between the division-ds markers is ' +
30
+ 'overwritten. Before finishing a screen, run `npx division-ds check <files>` (Node 22 or newer): it flags a raw colour, an ' +
31
+ 'invented token, touching buttons, an unknown prop and a wrong enum value, and says what to write instead.'
32
+
33
+ /** The rules as they read in someone else's file: titled with the system's name, since "## Rules"
34
+ * alone means nothing in a project with rules of its own. */
35
+ export function rulesBody(rules) {
36
+ return [rules.replace(/^## Rules\b/, '## Division DS rules'), '', NOTE].join('\n')
37
+ }
38
+
39
+ /** The marked block, start and end markers included. */
40
+ export const markedBlock = rules => `${START}\n${rulesBody(rules)}\n${END}`
41
+
42
+ /** The Cursor rule file: frontmatter that attaches it to JSX/TSX files, then the rules. */
43
+ export function cursorRule(rules) {
44
+ return [
45
+ '---',
46
+ 'description: Division DS rules. Which component and token to use, and the composition rules prop types cannot express. Apply when writing or changing UI built with Division DS components.',
47
+ 'globs: **/*.tsx, **/*.jsx',
48
+ 'alwaysApply: false',
49
+ '---',
50
+ '',
51
+ rulesBody(rules),
52
+ '',
53
+ ].join('\n')
54
+ }
55
+
56
+ const BLOCK = new RegExp(`${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}`)
57
+ function escapeRegExp(text) {
58
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
59
+ }
60
+
61
+ /** `existing` with the block inserted (appended after a blank line) or replaced in place. */
62
+ export function upsertBlock(existing, rules) {
63
+ const block = markedBlock(rules)
64
+ if (existing === null || existing.trim() === '') return block + '\n'
65
+ if (BLOCK.test(existing)) return existing.replace(BLOCK, () => block)
66
+ return existing.replace(/\s*$/, '') + '\n\n' + block + '\n'
67
+ }
68
+
69
+ /** `existing` with the block and the blank lines that framed it taken out. */
70
+ export function removeBlock(existing) {
71
+ if (!BLOCK.test(existing)) return existing
72
+ const without = existing.replace(new RegExp(`\\n*${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}\\n*`), '\n\n')
73
+ return without.replace(/^\n+/, '').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '') + (without.trim() ? '\n' : '')
74
+ }
75
+
76
+ const read = path => (existsSync(path) ? readFileSync(path, 'utf8') : null)
77
+
78
+ /**
79
+ * Every file change for one run, without writing anything:
80
+ * `[{ target, path, action, before, after }]`, where `action` is 'create', 'update', 'unchanged',
81
+ * 'delete' or 'absent' and `after` is null for a deletion. A file in CRLF stays in CRLF.
82
+ */
83
+ export function planRules({ targetDir, rules, only = TARGETS, remove = false }) {
84
+ return only.map(target => {
85
+ const path = join(targetDir, FILES[target])
86
+ const raw = read(path)
87
+ const crlf = raw !== null && raw.includes('\r\n')
88
+ const before = raw === null ? null : raw.replace(/\r\n/g, '\n')
89
+ let after
90
+ if (target === 'cursor') after = remove ? null : cursorRule(rules)
91
+ else if (remove) after = before === null ? null : removeBlock(before)
92
+ else after = upsertBlock(before, rules)
93
+ // A file holding nothing but the block is ours: removing the block removes the file.
94
+ if (remove && after !== null && after.trim() === '') after = null
95
+
96
+ let action
97
+ if (before === null) action = after === null ? 'absent' : 'create'
98
+ else if (after === null) action = 'delete'
99
+ else action = after === before ? 'unchanged' : 'update'
100
+ const restore = text => (text !== null && crlf ? text.replace(/\n/g, '\r\n') : text)
101
+ return { target, path, action, before: raw, after: restore(after) }
102
+ })
103
+ }
104
+
105
+ /** Writes a plan to disk. */
106
+ export function applyPlan(plan) {
107
+ for (const change of plan) {
108
+ if (change.action === 'create' || change.action === 'update') {
109
+ mkdirSync(dirname(change.path), { recursive: true })
110
+ writeFileSync(change.path, change.after)
111
+ } else if (change.action === 'delete') {
112
+ rmSync(change.path)
113
+ }
114
+ }
115
+ }
116
+
117
+ /** A line diff between two texts: the common head and tail are skipped, the middle is shown as
118
+ * `-` and `+` lines. Enough to review a block being replaced, which is the only change made. */
119
+ export function diff(before, after) {
120
+ const a = before === null ? [] : before.replace(/\r\n/g, '\n').split('\n')
121
+ const b = after === null ? [] : after.replace(/\r\n/g, '\n').split('\n')
122
+ let head = 0
123
+ while (head < a.length && head < b.length && a[head] === b[head]) head++
124
+ let tail = 0
125
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
126
+ const lines = []
127
+ if (head > 0) lines.push(`@@ ${head} unchanged line(s) @@`)
128
+ for (const line of a.slice(head, a.length - tail)) lines.push(`- ${line}`)
129
+ for (const line of b.slice(head, b.length - tail)) lines.push(`+ ${line}`)
130
+ if (tail > 0) lines.push(`@@ ${tail} unchanged line(s) @@`)
131
+ return lines.join('\n')
132
+ }
@@ -0,0 +1,125 @@
1
+ // The token-discipline gate's rules, as pure functions over text. The runner is
2
+ // scripts/check-token-discipline.mjs.
3
+ //
4
+ // Colour comes from a token or it does not ship. Until this gate existed the components were
5
+ // clean by convention only, and a convention is exactly what an agent new to the system cannot
6
+ // see: a hardcoded hex renders fine, and an invented token name (`var(--color-brand)`) fails
7
+ // silently, because an undefined custom property is not an error anywhere.
8
+ //
9
+ // Colour only, on purpose. Spacing is not gated: enforcing it before the scale is complete makes
10
+ // an author (or an agent) invent a token name to satisfy the rule, which is the worse failure.
11
+
12
+ export const SCANNED_ROOTS = ['src']
13
+ export const SCANNED_EXTENSIONS = /\.(tsx?|css)$/
14
+
15
+ /** Files where a literal colour is the point, each with its reason. Everything else is scanned.
16
+ * A new entry needs a reason as good as these; "it was easier" is the case the gate exists for. */
17
+ export const EXEMPT = [
18
+ { pattern: /^src\/styles\/tokens\.css$/, why: 'generated from tokens/: this is where the literals are meant to be' },
19
+ { pattern: /\.test\.tsx?$/, why: 'tests assert on literal values and on deliberately wrong input' },
20
+ { pattern: /^src\/demo\/(scene|blob|terrain)\//, why: 'WebGL materials: three.js takes colours as numbers, not CSS variables' },
21
+ { pattern: /^src\/components\/atoms\/loading-animation\/(palette|scene|kit|variants)\.ts$/, why: 'WebGL materials, read from the tokens at runtime where they can be' },
22
+ { pattern: /^src\/pages\/guidelines\//, why: 'the pages that display the palette, the ramps and the measured values themselves' },
23
+ ]
24
+
25
+ const TOKEN_NAMESPACE = /var\(\s*(--(?:color|component|spacing|duration|ease)-[a-z0-9-]*)/g
26
+ const PRIMITIVE_RAMP = /^--color-(signal|hostile|ok|info|warning|neutral)-\d+$/
27
+ const HEX = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
28
+ const COLOR_FUNCTION = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\(/g
29
+ const PALETTE_CLASS = new RegExp(
30
+ '(?<![\\w-])(?:bg|text|border|ring|outline|fill|stroke|from|via|to|decoration|divide|placeholder|caret|accent|shadow)-' +
31
+ '(?:(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-(?:50|[1-9]00|950)' +
32
+ '|white|black)(?![\\w-])',
33
+ 'g'
34
+ )
35
+
36
+ /** Blanks comments, keeping every newline so line numbers hold. A value quoted in a comment is
37
+ * documentation ("raised from #6f7a88"), not a colour on the page. */
38
+ export function stripComments(text) {
39
+ const blank = match => match.replace(/[^\n]/g, ' ')
40
+ return text.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/(^|[^:'"`\\])\/\/[^\n]*/g, (match, lead) => lead + blank(match.slice(lead.length)))
41
+ }
42
+
43
+ /** Up to three real tokens closest to a name that is not one, nearest first. */
44
+ export function suggest(name, tokenNames) {
45
+ const parts = name.split('-').filter(Boolean)
46
+ return tokenNames
47
+ .map(candidate => {
48
+ const other = candidate.split('-').filter(Boolean)
49
+ const shared = parts.filter(part => other.includes(part)).length
50
+ return { candidate, score: shared * 2 - Math.abs(other.length - parts.length) }
51
+ })
52
+ .filter(entry => entry.score > 2)
53
+ .sort((a, b) => b.score - a.score || a.candidate.localeCompare(b.candidate))
54
+ .slice(0, 3)
55
+ .map(entry => entry.candidate)
56
+ }
57
+
58
+ const ALLOW = /token-discipline: allow\b[\s,:-]*(.*?)\s*(?:\*\/\}?)?\s*$/
59
+
60
+ /** The line numbers a `token-discipline: allow <reason>` comment covers: its own line and the
61
+ * next one. The reason is required, at least a few words; a bare marker covers nothing, so the
62
+ * gate reports the line as if the comment were not there. */
63
+ export function allowedLines(text) {
64
+ const covered = new Set()
65
+ text.split('\n').forEach((line, index) => {
66
+ const match = line.match(ALLOW)
67
+ if (match && match[1].trim().split(/\s+/).length >= 3) {
68
+ covered.add(index + 1)
69
+ covered.add(index + 2)
70
+ }
71
+ })
72
+ return covered
73
+ }
74
+
75
+ /** How many allow comments a file carries, for the runner's summary line. */
76
+ export const countAllows = text => text.split('\n').filter(line => ALLOW.test(line)).length
77
+
78
+ const isExempt = path => EXEMPT.some(entry => entry.pattern.test(path))
79
+ const inComponents = path => /^src\/components\/(atoms|molecules|organisms)\//.test(path)
80
+
81
+ /**
82
+ * Every violation in one file: `{ path, line, rule, found, fix }`.
83
+ * `tokenNames` is every CSS variable the token build emits (public/manifest/tokens.json).
84
+ */
85
+ export function findViolations(text, path, tokenNames) {
86
+ if (isExempt(path)) return []
87
+ const known = new Set(tokenNames)
88
+ const violations = []
89
+ const allowed = allowedLines(text)
90
+ const lines = stripComments(text).split('\n')
91
+ lines.forEach((line, index) => {
92
+ if (allowed.has(index + 1)) return
93
+ const add = (rule, found, fix) => violations.push({ path, line: index + 1, rule, found, fix })
94
+
95
+ for (const match of line.matchAll(TOKEN_NAMESPACE)) {
96
+ const name = match[1]
97
+ // A name ending in a hyphen is the fixed part of a computed one: var(--color-accent-${tone}-fill).
98
+ if (name.endsWith('-')) continue
99
+ if (!known.has(name)) {
100
+ const near = suggest(name, tokenNames)
101
+ add('unknown-token', `var(${name})`, near.length ? `No such token. Did you mean ${near.join(', ')}?` : 'No such token. The full list is public/manifest/tokens.json.')
102
+ } else if (PRIMITIVE_RAMP.test(name) && inComponents(path)) {
103
+ add('primitive-in-component', `var(${name})`, 'A component reads semantic tokens, not a ramp step. Pick one by intent from public/manifest/tokens.json (accent.<state>.text, .fill or .ink).')
104
+ }
105
+ }
106
+
107
+ // What is left once every var(...) and every `--name: value` on a token is out of the way is
108
+ // where a literal colour would be.
109
+ const rest = line.replace(/var\([^)]*\)/g, '')
110
+ for (const match of rest.matchAll(HEX)) {
111
+ add('raw-colour', match[0], 'Use a semantic token: text-[var(--color-text-primary)], bg-[var(--color-surface-base)]. Tokens and what each is for: public/manifest/tokens.json.')
112
+ }
113
+ for (const match of rest.matchAll(COLOR_FUNCTION)) {
114
+ add('raw-colour', match[0] + '…)', 'Use a semantic token. If no token fits, the colour is a change to the system: add it under tokens/ with a contrast pair.')
115
+ }
116
+ for (const match of rest.matchAll(PALETTE_CLASS)) {
117
+ add('palette-class', match[0], 'Tailwind\'s palette is not this system\'s. Use an arbitrary value on a token: bg-[var(--color-surface-raised)], text-[var(--color-accent-hostile-text)].')
118
+ }
119
+ })
120
+ return violations
121
+ }
122
+
123
+ export function formatViolation({ path, line, rule, found, fix }) {
124
+ return `${path}:${line} [${rule}] ${found}\n ${fix}`
125
+ }