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,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// division-ds: the system's gates and rules, for a project that USES the components.
|
|
3
|
+
//
|
|
4
|
+
// division-ds check [files, dirs or globs...] token discipline, touching buttons, props
|
|
5
|
+
// division-ds rules [--target <dir>] ... install the agent rules into a project
|
|
6
|
+
// division-ds help
|
|
7
|
+
//
|
|
8
|
+
// Inside this repository `npm run verify` is the loop. A consumer project has none of that: an
|
|
9
|
+
// agent building a screen there gets no gate at all unless one is brought to it. This is that
|
|
10
|
+
// gate, reusing the same rule modules (scripts/lib/token-discipline.mjs, button-spacing.mjs) plus
|
|
11
|
+
// the props validator (props-validator.mjs), and the same AGENTS.md rules (agent-docs.mjs).
|
|
12
|
+
//
|
|
13
|
+
// Published to npm as `division-ds` (only this file, the modules it imports, the two manifests and
|
|
14
|
+
// AGENTS.md: scripts/build-cli-package.mjs), so `npx division-ds check src` works anywhere. From a
|
|
15
|
+
// clone, call it by path: `node <path-to-division-ds>/scripts/division-ds.mjs check src`. It needs
|
|
16
|
+
// Node 22+ (fs.globSync) and `typescript`, which the npm package declares as its one dependency.
|
|
17
|
+
import { existsSync, globSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
18
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
import { extractRules } from './lib/agent-docs.mjs'
|
|
21
|
+
import { findViolations as buttonSpacing, formatViolation as formatButtonSpacing } from './lib/button-spacing.mjs'
|
|
22
|
+
import { buildIndex, findPropViolations, formatPropViolation } from './lib/props-validator.mjs'
|
|
23
|
+
import { applyPlan, diff, planRules, TARGETS } from './lib/rules-installer.mjs'
|
|
24
|
+
import { findViolations as tokenDiscipline, formatViolation as formatTokenDiscipline } from './lib/token-discipline.mjs'
|
|
25
|
+
|
|
26
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
27
|
+
const DEFAULT_MANIFEST = join(ROOT, 'public/manifest/components.json')
|
|
28
|
+
const CHECKED = /\.(tsx|jsx|ts)$/
|
|
29
|
+
const SKIPPED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage'])
|
|
30
|
+
|
|
31
|
+
const HELP = `division-ds: Division DS gates and agent rules for a project that uses the components.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
division-ds check [paths...] [--manifest <path|url>] [--tokens <path|url>] [--json]
|
|
35
|
+
Checks .tsx, .jsx and .ts files (default: ./src). A path may be a file, a directory or a
|
|
36
|
+
glob ("src/**/*.tsx"). Runs three gates and exits 1 on any finding:
|
|
37
|
+
token-discipline raw colours, Tailwind palette classes, token names that do not exist
|
|
38
|
+
button-spacing two Buttons/IconButtons side by side with no gap on their parent
|
|
39
|
+
props an unknown prop or an enum value outside the union, on elements
|
|
40
|
+
imported from Division DS (components/atoms|molecules|organisms, or
|
|
41
|
+
the package name); a component of your own with the same name is skipped
|
|
42
|
+
--manifest components.json to check props against (default: the one beside this script)
|
|
43
|
+
--tokens tokens.json for token names (default: tokens.json beside the manifest)
|
|
44
|
+
--json one JSON object on stdout instead of text
|
|
45
|
+
|
|
46
|
+
division-ds rules [--target <dir>] [--only agents,cursor,gemini] [--dry-run] [--remove]
|
|
47
|
+
Writes the Division DS rules into a project: a marked block in AGENTS.md and GEMINI.md
|
|
48
|
+
(created if missing, replaced on re-run, your text around it untouched) and the file
|
|
49
|
+
.cursor/rules/division-ds.mdc. --dry-run prints the changes and writes nothing. --remove
|
|
50
|
+
takes the blocks out and deletes the .mdc (and a file left holding nothing).
|
|
51
|
+
|
|
52
|
+
division-ds help
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
function parseArgs(argv) {
|
|
56
|
+
const options = { _: [] }
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const arg = argv[i]
|
|
59
|
+
if (!arg.startsWith('--')) options._.push(arg)
|
|
60
|
+
else if (['--manifest', '--tokens', '--target', '--only'].includes(arg)) {
|
|
61
|
+
if (argv[i + 1] === undefined) usage(`${arg} needs a value.`)
|
|
62
|
+
options[arg.slice(2)] = argv[++i]
|
|
63
|
+
} else if (['--json', '--dry-run', '--remove'].includes(arg)) options[arg.slice(2)] = true
|
|
64
|
+
else usage(`Unknown option ${arg}.`)
|
|
65
|
+
}
|
|
66
|
+
return options
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function usage(message) {
|
|
70
|
+
console.error(`${message}\n\n${HELP}`)
|
|
71
|
+
process.exit(2)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function load(source) {
|
|
75
|
+
if (/^https?:\/\//.test(source)) {
|
|
76
|
+
const response = await fetch(source)
|
|
77
|
+
if (!response.ok) throw new Error(`${source}: HTTP ${response.status}`)
|
|
78
|
+
return response.json()
|
|
79
|
+
}
|
|
80
|
+
return JSON.parse(readFileSync(source, 'utf8'))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The files a list of arguments names: each a file, a directory (walked) or a glob. */
|
|
84
|
+
function expand(args) {
|
|
85
|
+
const files = new Set()
|
|
86
|
+
const walk = dir => {
|
|
87
|
+
for (const entry of readdirSync(dir)) {
|
|
88
|
+
if (SKIPPED_DIRS.has(entry)) continue
|
|
89
|
+
const path = join(dir, entry)
|
|
90
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
91
|
+
else if (CHECKED.test(entry) && !entry.endsWith('.d.ts')) files.add(path)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const arg of args) {
|
|
95
|
+
if (/[*?{[]/.test(arg)) {
|
|
96
|
+
for (const path of globSync(arg, { exclude: name => SKIPPED_DIRS.has(name.split(/[\\/]/).pop()) })) {
|
|
97
|
+
if (CHECKED.test(path) && !path.endsWith('.d.ts') && statSync(path).isFile()) files.add(resolve(path))
|
|
98
|
+
}
|
|
99
|
+
} else if (!existsSync(arg)) usage(`No such file or directory: ${arg}`)
|
|
100
|
+
else if (statSync(arg).isDirectory()) walk(resolve(arg))
|
|
101
|
+
else files.add(resolve(arg))
|
|
102
|
+
}
|
|
103
|
+
return [...files].sort()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function check(options) {
|
|
107
|
+
const manifestSource = options.manifest ?? DEFAULT_MANIFEST
|
|
108
|
+
const tokensSource = options.tokens ?? manifestSource.replace(/components\.json$/, 'tokens.json')
|
|
109
|
+
const [manifest, tokens] = await Promise.all([load(manifestSource), load(tokensSource)])
|
|
110
|
+
const index = buildIndex(manifest)
|
|
111
|
+
const tokenNames = tokens.tokens.map(token => token.name)
|
|
112
|
+
|
|
113
|
+
const args = options._.length > 0 ? options._ : existsSync('src') ? ['src'] : usage('No paths given and no ./src here.')
|
|
114
|
+
const files = expand(args)
|
|
115
|
+
const findings = []
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const path = relative(process.cwd(), file).split('\\').join('/')
|
|
118
|
+
const text = readFileSync(file, 'utf8')
|
|
119
|
+
for (const v of tokenDiscipline(text, path, tokenNames)) {
|
|
120
|
+
findings.push({ gate: 'token-discipline', path, line: v.line, rule: v.rule, found: v.found, fix: v.fix, text: formatTokenDiscipline(v) })
|
|
121
|
+
}
|
|
122
|
+
if (/\.[jt]sx$/.test(path)) {
|
|
123
|
+
for (const v of buttonSpacing(text, path)) {
|
|
124
|
+
findings.push({
|
|
125
|
+
gate: 'button-spacing',
|
|
126
|
+
path,
|
|
127
|
+
line: v.line,
|
|
128
|
+
column: v.column,
|
|
129
|
+
rule: 'touching-buttons',
|
|
130
|
+
found: `<${v.tag}> with ${v.count} button-like children`,
|
|
131
|
+
fix: 'Wrap the buttons in one parent with a flex/grid class and a gap- utility (flex flex-wrap items-center gap-3), and move any margin from the buttons onto it.',
|
|
132
|
+
text: formatButtonSpacing({ ...v, file: path }),
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const v of findPropViolations(text, path, index)) {
|
|
137
|
+
findings.push({ gate: 'props', path, line: v.line, column: v.column, rule: v.rule, found: v.found, fix: v.fix, text: formatPropViolation(v) })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (options.json) {
|
|
142
|
+
const out = findings.map(({ text: _text, ...finding }) => finding)
|
|
143
|
+
console.log(JSON.stringify({ files: files.length, manifest: manifestSource, findings: out }, null, 2))
|
|
144
|
+
} else if (findings.length > 0) {
|
|
145
|
+
console.error(`\ndivision-ds check FAILED: ${findings.length} finding(s) in ${files.length} file(s).\n`)
|
|
146
|
+
for (const gate of ['token-discipline', 'button-spacing', 'props']) {
|
|
147
|
+
const ofGate = findings.filter(finding => finding.gate === gate)
|
|
148
|
+
if (ofGate.length === 0) continue
|
|
149
|
+
console.error(`${gate} (${ofGate.length})`)
|
|
150
|
+
for (const finding of ofGate) console.error(' ' + finding.text)
|
|
151
|
+
if (gate === 'button-spacing') console.error(` ${ofGate[0].fix}`)
|
|
152
|
+
console.error('')
|
|
153
|
+
}
|
|
154
|
+
console.error('Each component\'s props, values and examples: /docs/components/<slug>.md. Tokens: public/manifest/tokens.json.')
|
|
155
|
+
} else {
|
|
156
|
+
console.log(`division-ds check passed: ${files.length} file(s), ${index.size} components, ${tokenNames.length} tokens, 0 findings.`)
|
|
157
|
+
}
|
|
158
|
+
process.exit(findings.length > 0 ? 1 : 0)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rules(options) {
|
|
162
|
+
const only = options.only ? options.only.split(',').map(target => target.trim()) : TARGETS
|
|
163
|
+
const unknown = only.filter(target => !TARGETS.includes(target))
|
|
164
|
+
if (unknown.length) usage(`--only takes ${TARGETS.join(', ')}; not ${unknown.join(', ')}.`)
|
|
165
|
+
const targetDir = resolve(options.target ?? '.')
|
|
166
|
+
if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) usage(`--target is not a directory: ${targetDir}`)
|
|
167
|
+
// This repository's AGENTS.md is the source; a block of its own rules appended to it would be
|
|
168
|
+
// read twice by every agent here, and regenerated nowhere.
|
|
169
|
+
if (resolve(targetDir) === resolve(ROOT)) usage('--target is Division DS itself. Point it at the project that uses the components.')
|
|
170
|
+
const source = readFileSync(join(ROOT, 'AGENTS.md'), 'utf8').replace(/\r\n/g, '\n')
|
|
171
|
+
const plan = planRules({ targetDir, rules: extractRules(source), only, remove: Boolean(options.remove) })
|
|
172
|
+
|
|
173
|
+
for (const change of plan) {
|
|
174
|
+
const shown = relative(process.cwd(), change.path) || change.path
|
|
175
|
+
console.log(`${options['dry-run'] ? 'would ' : ''}${change.action.padEnd(9)} ${shown}`)
|
|
176
|
+
if (options['dry-run'] && change.action !== 'unchanged' && change.action !== 'absent') {
|
|
177
|
+
console.log(diff(change.before, change.after).replace(/^/gm, ' '))
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (!options['dry-run']) applyPlan(plan)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
184
|
+
const options = parseArgs(rest)
|
|
185
|
+
if (command === 'check') await check(options)
|
|
186
|
+
else if (command === 'rules') rules(options)
|
|
187
|
+
else if (command === undefined || command === 'help' || command === '--help' || command === '-h') console.log(HELP)
|
|
188
|
+
else usage(`Unknown command "${command}".`)
|