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,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}".`)