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.
- package/AGENTS.md +190 -0
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/package.json +20 -4
- package/public/manifest/components.json +10550 -0
- package/public/manifest/tokens.json +932 -0
- package/scripts/division-ds.mjs +188 -0
- package/scripts/lib/agent-docs.mjs +510 -0
- package/scripts/lib/button-spacing.mjs +416 -0
- package/scripts/lib/component-props.mjs +181 -0
- package/scripts/lib/props-validator.mjs +320 -0
- package/scripts/lib/rules-installer.mjs +132 -0
- package/scripts/lib/token-discipline.mjs +125 -0
|
@@ -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
|
+
}
|