@nitra/cursor 1.13.2 → 1.13.11
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/CHANGELOG.md +102 -0
- package/bin/n-cursor.js +4 -2
- package/package.json +4 -2
- package/rules/ga/fix/workflows/check.mjs +6 -109
- package/rules/ga/ga.mdc +10 -15
- package/rules/ga/policy/package_json/package_json.rego +20 -0
- package/rules/ga/policy/package_json/target.json +8 -0
- package/rules/ga/policy/package_json/template/package.json.contains.json +1 -0
- package/rules/ga/policy/vscode_extensions/target.json +8 -0
- package/rules/ga/policy/vscode_extensions/template/extensions.json.snippet.json +1 -0
- package/rules/ga/policy/vscode_extensions/vscode_extensions.rego +18 -0
- package/rules/ga/policy/vscode_settings/target.json +8 -0
- package/rules/ga/policy/vscode_settings/template/settings.json.snippet.json +1 -0
- package/rules/ga/policy/vscode_settings/vscode_settings.rego +22 -0
- package/rules/ga/policy/zizmor_yml/target.json +8 -0
- package/rules/ga/policy/zizmor_yml/template/zizmor.yml.snippet.yml +5 -0
- package/rules/ga/policy/zizmor_yml/zizmor_yml.rego +27 -0
- package/rules/js-lint/fix/tooling/check.mjs +6 -83
- package/rules/js-lint/policy/jscpd/jscpd.rego +38 -0
- package/rules/js-lint/policy/jscpd/target.json +8 -0
- package/rules/js-lint/policy/vscode_extensions/target.json +8 -0
- package/rules/js-lint/policy/vscode_extensions/vscode_extensions.rego +25 -0
- package/rules/rego/lint/lint.mjs +5 -4
- package/rules/rego/policy/package_json/package_json.rego +8 -29
- package/rules/rego/policy/package_json/template/package.json.snippet.json +1 -0
- package/rules/rego/policy/vscode_extensions/template/extensions.json.snippet.json +1 -0
- package/rules/rego/policy/vscode_extensions/vscode_extensions.rego +7 -11
- package/rules/rego/policy/vscode_settings/template/settings.json.snippet.json +6 -0
- package/rules/rego/policy/vscode_settings/vscode_settings.rego +19 -27
- package/rules/rego/rego.mdc +10 -8
- package/rules/security/fix/gitleaks/check.mjs +8 -45
- package/rules/security/fix/gitleaks/template/.gitleaks.toml.snippet.toml +12 -0
- package/rules/security/policy/gitleaks/gitleaks.rego +17 -0
- package/rules/security/policy/gitleaks/target.json +8 -0
- package/rules/security/policy/package_json/package_json.rego +22 -59
- package/rules/security/policy/package_json/template/package.json.contains.json +1 -0
- package/rules/security/policy/package_json/template/package.json.deny.json +4 -0
- package/rules/security/policy/package_json/template/package.json.snippet.json +1 -0
- package/rules/security/security.mdc +7 -26
- package/rules/security/todo.MD +27 -0
- package/rules/vue/fix/packages/check.mjs +7 -64
- package/rules/vue/policy/package_json/package_json.rego +45 -2
- package/rules/vue/vue.mdc +15 -2
- package/scripts/ensure-nitra-cursor-dev-dependencies.mjs +41 -21
- package/scripts/utils/check-mdc-template-refs.mjs +47 -0
- package/scripts/utils/inline-template-links.mjs +60 -0
- package/scripts/utils/run-conftest-batch.mjs +60 -33
- package/scripts/utils/run-rule.mjs +16 -1
- package/scripts/utils/template.mjs +215 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads template/ for a concern directory and returns a merged structure indexed
|
|
3
|
+
* by target basename. For each <target>, returns whichever of snippet/deny/contains
|
|
4
|
+
* exist (parsed in native format by extension).
|
|
5
|
+
*
|
|
6
|
+
* @param {string} concernDir absolute path to fix/<concern>/ or policy/<concern>/
|
|
7
|
+
* @returns {Promise<Record<string, { snippet?: any, deny?: any, contains?: any }>>}
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { basename as _basename, extname, join, relative } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { parse as parseToml } from 'smol-toml'
|
|
14
|
+
|
|
15
|
+
const SLOTS = ['snippet', 'deny', 'contains']
|
|
16
|
+
|
|
17
|
+
/** Parse file contents by extension; returns JS object for structured formats, string for text. */
|
|
18
|
+
async function parseByExt(path) {
|
|
19
|
+
const raw = await readFile(path, 'utf8')
|
|
20
|
+
const ext = extname(path).toLowerCase()
|
|
21
|
+
if (ext === '.json' || ext === '.jsonc') return JSON.parse(stripJsonComments(raw))
|
|
22
|
+
if (ext === '.toml') return parseToml(raw)
|
|
23
|
+
if (ext === '.yml' || ext === '.yaml') {
|
|
24
|
+
const { parse: parseYaml } = await import('yaml')
|
|
25
|
+
return parseYaml(raw)
|
|
26
|
+
}
|
|
27
|
+
return raw // text-only
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stripJsonComments(s) {
|
|
31
|
+
// Minimal: strip // line comments and /* */ block comments. JSON-with-comments format.
|
|
32
|
+
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function walk(dir, base = dir) {
|
|
36
|
+
const out = []
|
|
37
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
38
|
+
const full = join(dir, entry.name)
|
|
39
|
+
if (entry.isDirectory()) out.push(...(await walk(full, base)))
|
|
40
|
+
else out.push(relative(base, full))
|
|
41
|
+
}
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse "<target>.<slot>.<ext>" or "<target>" (text-only).
|
|
47
|
+
* Returns { target, slot } where slot is one of snippet|deny|contains|null (null = text-only target).
|
|
48
|
+
*/
|
|
49
|
+
function classifyTemplateFile(relPath) {
|
|
50
|
+
// Try ".<slot>." suffix detection
|
|
51
|
+
for (const slot of SLOTS) {
|
|
52
|
+
const m = relPath.match(new RegExp(`^(?<target>.+)\\.${slot}\\.[^.]+$`))
|
|
53
|
+
if (m?.groups?.target) return { target: m.groups.target, slot }
|
|
54
|
+
}
|
|
55
|
+
// No slot suffix → text-only canon for the literal target name
|
|
56
|
+
return { target: relPath, slot: null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function formatPath(parts) {
|
|
60
|
+
return parts
|
|
61
|
+
.map(p => (typeof p === 'number' ? `[${p}]` : /^[a-zA-Z_$][\w$]*$/.test(p) ? p : JSON.stringify(p)))
|
|
62
|
+
.reduce((acc, p) => (acc === '' ? p : p.startsWith('[') ? acc + p : acc + '.' + p), '')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function quote(v) {
|
|
66
|
+
return typeof v === 'string' ? JSON.stringify(v) : String(v)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Deep subset-of check. Every leaf in `snippet` must equal same path in `actual`.
|
|
71
|
+
* Arrays in snippet: every element must be present in actual array.
|
|
72
|
+
* Returns array of violation messages.
|
|
73
|
+
*/
|
|
74
|
+
export function checkSnippet(actual, snippet, opts, path = []) {
|
|
75
|
+
if (snippet == null) return []
|
|
76
|
+
const { targetPath, source } = opts
|
|
77
|
+
const violations = []
|
|
78
|
+
if (Array.isArray(snippet)) {
|
|
79
|
+
if (!Array.isArray(actual)) {
|
|
80
|
+
violations.push(`${targetPath}: ${formatPath(path)} має бути масивом (${source})`)
|
|
81
|
+
return violations
|
|
82
|
+
}
|
|
83
|
+
for (const needle of snippet) {
|
|
84
|
+
const found = actual.some(a => JSON.stringify(a) === JSON.stringify(needle))
|
|
85
|
+
if (!found) {
|
|
86
|
+
violations.push(`${targetPath}: ${formatPath(path)} має містити ${quote(needle)} (${source})`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return violations
|
|
90
|
+
}
|
|
91
|
+
if (snippet !== null && typeof snippet === 'object') {
|
|
92
|
+
if (actual == null || typeof actual !== 'object' || Array.isArray(actual)) {
|
|
93
|
+
violations.push(`${targetPath}: ${formatPath(path)} має бути об'єктом (${source})`)
|
|
94
|
+
return violations
|
|
95
|
+
}
|
|
96
|
+
for (const [k, v] of Object.entries(snippet)) {
|
|
97
|
+
violations.push(...checkSnippet(actual[k], v, opts, [...path, k]))
|
|
98
|
+
}
|
|
99
|
+
return violations
|
|
100
|
+
}
|
|
101
|
+
// Leaf (string/number/boolean)
|
|
102
|
+
if (actual !== snippet) {
|
|
103
|
+
violations.push(`${targetPath}: ${formatPath(path)} має бути ${quote(snippet)} (${source})`)
|
|
104
|
+
}
|
|
105
|
+
return violations
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Walks deny tree; for any leaf path that exists in actual, returns violation
|
|
110
|
+
* with the deny's leaf string as reason.
|
|
111
|
+
*/
|
|
112
|
+
export function checkDeny(actual, deny, opts, path = []) {
|
|
113
|
+
if (deny == null) return []
|
|
114
|
+
const { targetPath, source } = opts
|
|
115
|
+
if (deny !== null && typeof deny === 'object' && !Array.isArray(deny)) {
|
|
116
|
+
const out = []
|
|
117
|
+
for (const [k, v] of Object.entries(deny)) {
|
|
118
|
+
const childActual = actual && typeof actual === 'object' ? actual[k] : undefined
|
|
119
|
+
out.push(...checkDeny(childActual, v, opts, [...path, k]))
|
|
120
|
+
}
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
// Leaf reached — if actual has this path at all (any value), it's a violation
|
|
124
|
+
if (actual !== undefined) {
|
|
125
|
+
const reason = typeof deny === 'string' ? deny : 'заборонено'
|
|
126
|
+
return [`${targetPath}: ${formatPath(path)} — ${reason} (${source})`]
|
|
127
|
+
}
|
|
128
|
+
return []
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* For each leaf path that has an array of strings in `contains`, every string
|
|
133
|
+
* must appear as substring in the same path of `actual` (string leaf).
|
|
134
|
+
*/
|
|
135
|
+
export function checkContains(actual, contains, opts, path = []) {
|
|
136
|
+
if (contains == null) return []
|
|
137
|
+
const { targetPath, source } = opts
|
|
138
|
+
if (Array.isArray(contains)) {
|
|
139
|
+
const out = []
|
|
140
|
+
const haystack = typeof actual === 'string' ? actual : ''
|
|
141
|
+
for (const needle of contains) {
|
|
142
|
+
if (!haystack.includes(needle)) {
|
|
143
|
+
out.push(`${targetPath}: ${formatPath(path)} має містити ${quote(needle)} (${source})`)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
if (contains !== null && typeof contains === 'object') {
|
|
149
|
+
const out = []
|
|
150
|
+
for (const [k, v] of Object.entries(contains)) {
|
|
151
|
+
const childActual = actual && typeof actual === 'object' ? actual[k] : undefined
|
|
152
|
+
out.push(...checkContains(childActual, v, opts, [...path, k]))
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* For text-only targets (e.g. .stylelintignore): every non-empty, non-comment
|
|
161
|
+
* line in `template` must appear (trimmed) in `actual`.
|
|
162
|
+
*/
|
|
163
|
+
export function checkTextSubset(actual, template, opts) {
|
|
164
|
+
if (template == null) return []
|
|
165
|
+
const { targetPath, source } = opts
|
|
166
|
+
const actualLines = new Set(String(actual ?? '').split(/\r?\n/).map(l => l.trim()))
|
|
167
|
+
const out = []
|
|
168
|
+
for (const raw of String(template).split(/\r?\n/)) {
|
|
169
|
+
const line = raw.trim()
|
|
170
|
+
if (line === '' || line.startsWith('#')) continue
|
|
171
|
+
if (!actualLines.has(line)) {
|
|
172
|
+
out.push(`${targetPath}: відсутній рядок ${quote(line)} (${source})`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function loadTemplate(concernDir) {
|
|
179
|
+
const tplDir = join(concernDir, 'template')
|
|
180
|
+
if (!existsSync(tplDir)) return {}
|
|
181
|
+
if (!(await stat(tplDir)).isDirectory()) return {}
|
|
182
|
+
const files = await walk(tplDir)
|
|
183
|
+
const result = {}
|
|
184
|
+
for (const rel of files) {
|
|
185
|
+
const { target, slot } = classifyTemplateFile(rel)
|
|
186
|
+
if (!result[target]) result[target] = {}
|
|
187
|
+
const value = await parseByExt(join(tplDir, rel))
|
|
188
|
+
if (slot === null) result[target].snippet = value // text-only treated as snippet
|
|
189
|
+
else result[target][slot] = value
|
|
190
|
+
}
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolves which template[<target>] to pass for a concern, based on its target.json.
|
|
196
|
+
* For `single` targets — basename. For `walkGlob` — basename of first non-negated entry.
|
|
197
|
+
* @param {string} concernAbsDir absolute path to fix/<concern>/ or policy/<concern>/
|
|
198
|
+
* @param {{ files?: { single?: string, walkGlob?: string|string[] } }} targetJson parsed target.json
|
|
199
|
+
* @returns {Promise<object|undefined>} template tree for the resolved target basename, or undefined
|
|
200
|
+
*/
|
|
201
|
+
export async function resolveConcernTemplateData(concernAbsDir, targetJson) {
|
|
202
|
+
const tpl = await loadTemplate(concernAbsDir)
|
|
203
|
+
const single = targetJson?.files?.single
|
|
204
|
+
if (single) return tpl[_basename(single)]
|
|
205
|
+
const glob = targetJson?.files?.walkGlob
|
|
206
|
+
if (typeof glob === 'string') return tpl[_basename(glob.replace(/^!/, ''))]
|
|
207
|
+
if (Array.isArray(glob)) {
|
|
208
|
+
for (const g of glob) {
|
|
209
|
+
if (g.startsWith('!')) continue
|
|
210
|
+
const data = tpl[_basename(g)]
|
|
211
|
+
if (data) return data
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined
|
|
215
|
+
}
|