@nitra/cursor 1.13.1 → 1.13.8
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/.claude-template/hooks/capture-decisions.sh +31 -13
- package/.claude-template/hooks/normalize-decisions.sh +8 -3
- package/CHANGELOG.md +65 -0
- package/bin/n-cursor.js +4 -2
- package/package.json +4 -2
- package/rules/adr/adr.mdc +14 -4
- package/rules/ga/fix/workflows/check.mjs +6 -109
- package/rules/ga/policy/package_json/package_json.rego +24 -0
- package/rules/ga/policy/package_json/target.json +8 -0
- package/rules/ga/policy/vscode_extensions/target.json +8 -0
- package/rules/ga/policy/vscode_extensions/vscode_extensions.rego +16 -0
- package/rules/ga/policy/vscode_settings/target.json +8 -0
- package/rules/ga/policy/vscode_settings/vscode_settings.rego +24 -0
- package/rules/ga/policy/zizmor_yml/target.json +8 -0
- package/rules/ga/policy/zizmor_yml/zizmor_yml.rego +17 -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/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/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
package/rules/vue/vue.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.9'
|
|
4
4
|
globs: "**/*.vue"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -104,7 +104,20 @@ const additionalInstructions = `
|
|
|
104
104
|
|
|
105
105
|
### Тестування
|
|
106
106
|
|
|
107
|
-
- **Unit
|
|
107
|
+
- **Unit:** **Bun Test Runner** (`bun test`) — використовуй його замість **Vitest**. API сумісне (`describe` / `it` / `expect` з `bun:test`), моки через `mock` і `mock.module`. У `package.json` тримай `"test": "bun test <шляхи>"`; не додавай `vitest`, `vitest.config.*` і пов’язані залежності.
|
|
108
|
+
- **Component / DOM:** **Vue Test Utils** + **Bun Test Runner** з **happy-dom** як DOM-середовищем. Підключення — через `@happy-dom/global-registrator` у preload-файлі та `bunfig.toml`:
|
|
109
|
+
|
|
110
|
+
```toml title="bunfig.toml"
|
|
111
|
+
[test]
|
|
112
|
+
preload = ["./test/happy-dom.preload.js"]
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
```js title="test/happy-dom.preload.js"
|
|
116
|
+
import { GlobalRegistrator } from '@happy-dom/global-registrator'
|
|
117
|
+
GlobalRegistrator.register()
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`jsdom` не використовуй — happy-dom швидший і достатній для типових Vue-компонентних тестів.
|
|
108
121
|
- **E2E:** **Playwright** змістовні сценарії користувацьких потоків.
|
|
109
122
|
|
|
110
123
|
### Інструменти (узгоджено з Vite і цим правилом)
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Дописує `\@nitra/cursor` у `devDependencies`
|
|
3
|
-
* оголошено ні в `devDependencies`, ні в `dependencies`.
|
|
2
|
+
* Дописує `\@nitra/cursor` у `devDependencies` workspace-root `package.json` проєкту, якщо пакет ще
|
|
3
|
+
* не оголошено ні в `devDependencies`, ні в `dependencies`.
|
|
4
4
|
*
|
|
5
5
|
* Використовується CLI `n-cursor` при кожному запуску (`npx \@nitra/cursor`, зокрема `check`), щоб
|
|
6
6
|
* команда `check` і скрипти з `node_modules/\@nitra/cursor/scripts/` були відтворювані після
|
|
7
|
-
* `bun install` / `npm install`, а не лише з кешу npx.
|
|
7
|
+
* `bun install` / `npm install`, а не лише з кешу npx. Корінь визначається тільки за наявністю поля
|
|
8
|
+
* `workspaces` у `package.json` поруч із поточною директорією запуску.
|
|
8
9
|
*
|
|
9
10
|
* Версія діапазону: `^<version>` з поля `version` установленого пакету `\@nitra/cursor`.
|
|
10
11
|
*/
|
|
@@ -37,36 +38,55 @@ export async function readBundledPackageVersion() {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @
|
|
43
|
-
* @param {{ bundledVersion?: string | null, silent?: boolean }} [options] `bundledVersion` — для тестів;
|
|
44
|
-
* `silent` — не писати в консоль при успішному оновленні
|
|
45
|
-
* @returns {Promise<boolean>} `true`, якщо `package.json` змінено на диску
|
|
41
|
+
* Читає JSON-обʼєкт із диска.
|
|
42
|
+
* @param {string} path шлях до JSON-файлу
|
|
43
|
+
* @returns {Promise<Record<string, unknown> | null>} обʼєкт або `null`, якщо файл нечитабельний
|
|
46
44
|
*/
|
|
47
|
-
|
|
48
|
-
const pkgPath = join(root, 'package.json')
|
|
49
|
-
if (!existsSync(pkgPath)) {
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
|
|
45
|
+
async function readJsonObject(path) {
|
|
53
46
|
let raw
|
|
54
47
|
try {
|
|
55
|
-
raw = await readFile(
|
|
48
|
+
raw = await readFile(path, 'utf8')
|
|
56
49
|
} catch {
|
|
57
|
-
return
|
|
50
|
+
return null
|
|
58
51
|
}
|
|
59
52
|
|
|
60
|
-
let pkg
|
|
61
53
|
try {
|
|
62
|
-
|
|
54
|
+
const value = JSON.parse(raw)
|
|
55
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
63
56
|
} catch {
|
|
64
|
-
return
|
|
57
|
+
return null
|
|
65
58
|
}
|
|
59
|
+
}
|
|
66
60
|
|
|
67
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Читає `package.json` поруч зі стартовою директорією, якщо це workspace-root.
|
|
63
|
+
* @param {string} startDir директорія, з якої запущено CLI
|
|
64
|
+
* @returns {Promise<{ path: string, pkg: Record<string, unknown> } | null>} workspace-root package або `null`
|
|
65
|
+
*/
|
|
66
|
+
async function readAdjacentWorkspaceRootPackageJson(startDir) {
|
|
67
|
+
const pkgPath = join(startDir, 'package.json')
|
|
68
|
+
if (!existsSync(pkgPath)) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const pkg = await readJsonObject(pkgPath)
|
|
73
|
+
return pkg && Object.hasOwn(pkg, 'workspaces') ? { path: pkgPath, pkg } : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Якщо у workspace-root `package.json` немає `\@nitra/cursor` у `devDependencies` і `dependencies`,
|
|
78
|
+
* дописує `devDependencies["\@nitra/cursor"]` зі значенням `^<bundledVersion>`.
|
|
79
|
+
* @param {string} root стартова директорія проєкту (зазвичай `process.cwd()`)
|
|
80
|
+
* @param {{ bundledVersion?: string | null, silent?: boolean }} [options] `bundledVersion` — для тестів;
|
|
81
|
+
* `silent` — не писати в консоль при успішному оновленні
|
|
82
|
+
* @returns {Promise<boolean>} `true`, якщо `package.json` змінено на диску
|
|
83
|
+
*/
|
|
84
|
+
export async function ensureNitraCursorInRootDevDependencies(root, options = {}) {
|
|
85
|
+
const workspaceRoot = await readAdjacentWorkspaceRootPackageJson(root)
|
|
86
|
+
if (!workspaceRoot) {
|
|
68
87
|
return false
|
|
69
88
|
}
|
|
89
|
+
const { path: pkgPath, pkg } = workspaceRoot
|
|
70
90
|
|
|
71
91
|
const devDeps = pkg.devDependencies
|
|
72
92
|
const deps = pkg.dependencies
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns list of template/ files that are NOT referenced in <id>.mdc as
|
|
3
|
+
* markdown link targets. Paths returned are relative to ruleDir.
|
|
4
|
+
*
|
|
5
|
+
* @param {string} ruleDir absolute path to npm/rules/<id>/
|
|
6
|
+
* @param {string} ruleId basename (e.g. "security")
|
|
7
|
+
* @returns {Promise<string[]>}
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
10
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
11
|
+
import { join, relative } from 'node:path'
|
|
12
|
+
|
|
13
|
+
async function walkTemplateDirs(ruleDir) {
|
|
14
|
+
const out = []
|
|
15
|
+
for (const kind of ['fix', 'policy']) {
|
|
16
|
+
const kindDir = join(ruleDir, kind)
|
|
17
|
+
if (!existsSync(kindDir)) continue
|
|
18
|
+
for (const concern of await readdir(kindDir)) {
|
|
19
|
+
const tpl = join(kindDir, concern, 'template')
|
|
20
|
+
if (!existsSync(tpl)) continue
|
|
21
|
+
if (!(await stat(tpl)).isDirectory()) continue
|
|
22
|
+
out.push(...(await collectFiles(tpl)))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out.map(p => relative(ruleDir, p))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function collectFiles(dir) {
|
|
29
|
+
const out = []
|
|
30
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
31
|
+
const full = join(dir, entry.name)
|
|
32
|
+
if (entry.isDirectory()) out.push(...(await collectFiles(full)))
|
|
33
|
+
else out.push(full)
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function findMissingMdcRefs(ruleDir, ruleId) {
|
|
39
|
+
const mdcPath = join(ruleDir, `${ruleId}.mdc`)
|
|
40
|
+
if (!existsSync(mdcPath)) return []
|
|
41
|
+
const mdc = await readFile(mdcPath, 'utf8')
|
|
42
|
+
const allFiles = await walkTemplateDirs(ruleDir)
|
|
43
|
+
return allFiles.filter(rel => {
|
|
44
|
+
// Match markdown link to ./<rel> or (<rel>) anywhere in the .mdc
|
|
45
|
+
return !mdc.includes(`./${rel}`) && !mdc.includes(`(${rel})`)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, extname, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const TEMPLATE_LINK_RE = /\[([^\]]+)\]\((\.\/[^)]*\/template\/[^)]+)\)/g
|
|
6
|
+
const SLOTS = ['snippet', 'deny', 'contains']
|
|
7
|
+
|
|
8
|
+
/** @param {string} filePath */
|
|
9
|
+
function langFromExt(filePath) {
|
|
10
|
+
const ext = extname(filePath)
|
|
11
|
+
if (ext === '.json') return 'json'
|
|
12
|
+
if (ext === '.toml') return 'toml'
|
|
13
|
+
if (ext === '.yml' || ext === '.yaml') return 'yaml'
|
|
14
|
+
return ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Strip `.<slot>.<ext>` suffix (slot ∈ snippet/deny/contains) to recover the
|
|
18
|
+
// real target file name (e.g. `package.json.snippet.json` → `package.json`).
|
|
19
|
+
/** @param {string} fileBasename */
|
|
20
|
+
function normalizeTargetName(fileBasename) {
|
|
21
|
+
for (const slot of SLOTS) {
|
|
22
|
+
const m = fileBasename.match(new RegExp(`^(.+)\\.${slot}\\.[^.]+$`))
|
|
23
|
+
if (m) return m[1]
|
|
24
|
+
}
|
|
25
|
+
return fileBasename
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Finds markdown links whose path contains /template/ and replaces them with
|
|
30
|
+
* inline fenced blocks. Reads file from join(ruleDir, rel-path).
|
|
31
|
+
* Throws Error if a matched link target doesn't exist (fail loud — user must know).
|
|
32
|
+
*
|
|
33
|
+
* @param {string} text .mdc file contents
|
|
34
|
+
* @param {string} ruleDir absolute path to the rule directory (e.g. .../npm/rules/security/)
|
|
35
|
+
* @returns {Promise<string>} transformed text
|
|
36
|
+
*/
|
|
37
|
+
export async function inlineTemplateLinks(text, ruleDir) {
|
|
38
|
+
const matches = [...text.matchAll(TEMPLATE_LINK_RE)]
|
|
39
|
+
if (matches.length === 0) return text
|
|
40
|
+
|
|
41
|
+
let result = text
|
|
42
|
+
for (const match of matches) {
|
|
43
|
+
const [fullMatch, , href] = match
|
|
44
|
+
// href starts with ./ and contains /template/ — already guaranteed by regex
|
|
45
|
+
const relPath = href.slice(2) // strip leading ./
|
|
46
|
+
const absPath = join(ruleDir, relPath)
|
|
47
|
+
|
|
48
|
+
if (!existsSync(absPath)) {
|
|
49
|
+
throw new Error(`inlineTemplateLinks: file not found: ${absPath} (referenced from .mdc)`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const contents = (await readFile(absPath, 'utf8')).trim()
|
|
53
|
+
const lang = langFromExt(absPath)
|
|
54
|
+
const targetName = normalizeTargetName(basename(absPath))
|
|
55
|
+
const replacement = `\`${targetName}\`:\n\n\`\`\`${lang}\n${contents}\n\`\`\``
|
|
56
|
+
result = result.replace(fullMatch, () => replacement)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* робить для opa/regal).
|
|
16
16
|
*/
|
|
17
17
|
import { spawnSync } from 'node:child_process'
|
|
18
|
-
import { existsSync } from 'node:fs'
|
|
18
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
19
20
|
import { dirname, join } from 'node:path'
|
|
20
21
|
import { fileURLToPath } from 'node:url'
|
|
21
22
|
|
|
@@ -57,8 +58,30 @@ function failConftestMissing() {
|
|
|
57
58
|
* @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
|
|
58
59
|
* @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
|
|
59
60
|
* @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
|
|
61
|
+
* @property {object} [templateData] опціональне merged-дерево; серіалізується у JSON `{ "template": <data> }` і передається як `--data <tmpfile>` (cleanup після завершення)
|
|
60
62
|
*/
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Pure args builder for conftest test. Extracted for unit-testability.
|
|
66
|
+
* Preserves the existing args layout (files before -p; --output json --no-color
|
|
67
|
+
* for parseable output); inserts --data right after --namespace when provided.
|
|
68
|
+
* @param {{ policyAbs: string, namespace: string, files: string[], extraArgs: string[], tmpDataFile: string|null }} p
|
|
69
|
+
* @returns {string[]}
|
|
70
|
+
*/
|
|
71
|
+
export function buildConftestArgs(p) {
|
|
72
|
+
const args = [
|
|
73
|
+
'test',
|
|
74
|
+
...p.files,
|
|
75
|
+
'-p',
|
|
76
|
+
p.policyAbs,
|
|
77
|
+
'--namespace',
|
|
78
|
+
p.namespace
|
|
79
|
+
]
|
|
80
|
+
if (p.tmpDataFile) args.push('--data', p.tmpDataFile)
|
|
81
|
+
args.push('--output', 'json', '--no-color', ...p.extraArgs)
|
|
82
|
+
return args
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
/**
|
|
63
86
|
* Виконує `conftest test` для всіх файлів одним спавном і повертає масив
|
|
64
87
|
* порушень. Якщо `files` порожній — повертає `[]` без спавна. Якщо `conftest`
|
|
@@ -81,40 +104,44 @@ export function runConftestBatch(opts) {
|
|
|
81
104
|
if (!existsSync(policyAbs)) {
|
|
82
105
|
throw new Error(`runConftestBatch: rego-каталог не знайдено: ${policyAbs}`)
|
|
83
106
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
'-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
opts.namespace,
|
|
91
|
-
'--output',
|
|
92
|
-
'json',
|
|
93
|
-
'--no-color',
|
|
94
|
-
...(opts.extraArgs ?? [])
|
|
95
|
-
]
|
|
96
|
-
const result = spawnSync(conftestBin, args, { encoding: 'utf8' })
|
|
97
|
-
if (result.error) {
|
|
98
|
-
throw result.error
|
|
107
|
+
let tmpDataDir = null
|
|
108
|
+
let tmpDataFile = null
|
|
109
|
+
if (opts.templateData) {
|
|
110
|
+
tmpDataDir = mkdtempSync(join(tmpdir(), 'n-cursor-tpl-'))
|
|
111
|
+
tmpDataFile = join(tmpDataDir, 'template-data.json')
|
|
112
|
+
writeFileSync(tmpDataFile, JSON.stringify({ template: opts.templateData }))
|
|
99
113
|
}
|
|
100
|
-
// conftest exit 1 = є failures (це валідно для нас); >1 = справжня помилка.
|
|
101
|
-
if (result.status !== 0 && result.status !== 1) {
|
|
102
|
-
throw new Error(`conftest exit ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
|
|
103
|
-
}
|
|
104
|
-
/** @type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>} */
|
|
105
|
-
let parsed
|
|
106
114
|
try {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const args = buildConftestArgs({
|
|
116
|
+
policyAbs,
|
|
117
|
+
namespace: opts.namespace,
|
|
118
|
+
files: opts.files,
|
|
119
|
+
extraArgs: opts.extraArgs ?? [],
|
|
120
|
+
tmpDataFile
|
|
121
|
+
})
|
|
122
|
+
const result = spawnSync(conftestBin, args, { encoding: 'utf8' })
|
|
123
|
+
if (result.error) throw result.error
|
|
124
|
+
// conftest exit 1 = є failures (це валідно для нас); >1 = справжня помилка.
|
|
125
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
126
|
+
throw new Error(`conftest exit ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
|
|
127
|
+
}
|
|
128
|
+
/** @type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>} */
|
|
129
|
+
let parsed
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(result.stdout)
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error(`conftest stdout не парситься як JSON: ${(result.stdout || '').slice(0, 200)}`)
|
|
134
|
+
}
|
|
135
|
+
/** @type {ConftestViolation[]} */
|
|
136
|
+
const out = []
|
|
137
|
+
for (const entry of parsed) {
|
|
138
|
+
const failures = entry.failures ?? []
|
|
139
|
+
for (const f of failures) {
|
|
140
|
+
out.push({ filename: entry.filename, namespace: entry.namespace, message: f.msg })
|
|
141
|
+
}
|
|
117
142
|
}
|
|
143
|
+
return out
|
|
144
|
+
} finally {
|
|
145
|
+
if (tmpDataDir) rmSync(tmpDataDir, { recursive: true, force: true })
|
|
118
146
|
}
|
|
119
|
-
return out
|
|
120
147
|
}
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
import { readFile } from 'node:fs/promises'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
17
|
|
|
18
|
+
import { findMissingMdcRefs } from './check-mdc-template-refs.mjs'
|
|
18
19
|
import { createCheckReporter } from './check-reporter.mjs'
|
|
19
20
|
import { resolveTargetFiles } from './resolve-target-files.mjs'
|
|
20
21
|
import { runConftestBatch } from './run-conftest-batch.mjs'
|
|
22
|
+
import { resolveConcernTemplateData } from './template.mjs'
|
|
21
23
|
|
|
22
24
|
const APPLIES_CONCERN_NAME = 'applies'
|
|
23
25
|
|
|
@@ -80,10 +82,13 @@ async function runPolicyConcern(bundledRulesDir, ruleId, concernName, walkCache)
|
|
|
80
82
|
// Rego не дозволяє '-' в імені пакета, тому kebab-id у `.n-cursor.json:rules`
|
|
81
83
|
// мапиться на snake у namespace. Файлова структура `rules/<id>/policy/` лишається kebab.
|
|
82
84
|
const regoNamespace = `${ruleId.replaceAll('-', '_')}.${concernName}`
|
|
85
|
+
const concernAbsDir = join(bundledRulesDir, ruleId, 'policy', concernName)
|
|
86
|
+
const templateData = await resolveConcernTemplateData(concernAbsDir, target)
|
|
83
87
|
const violations = runConftestBatch({
|
|
84
88
|
policyDirRel: `${ruleId}/${concernName}`,
|
|
85
89
|
namespace: regoNamespace,
|
|
86
|
-
files
|
|
90
|
+
files,
|
|
91
|
+
templateData
|
|
87
92
|
})
|
|
88
93
|
if (violations.length === 0) {
|
|
89
94
|
reporter.pass(`${concernName}: ${files.length} файл(ів) OK (rego)`)
|
|
@@ -127,5 +132,15 @@ export async function runRule(rule, bundledRulesDir, walkCache) {
|
|
|
127
132
|
if (code !== 0) totalCode = 1
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
const ruleDir = join(bundledRulesDir, rule.id)
|
|
136
|
+
const missing = await findMissingMdcRefs(ruleDir, rule.id)
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
const reporter = createCheckReporter()
|
|
139
|
+
for (const rel of missing) {
|
|
140
|
+
reporter.fail(`${rule.id}.mdc: відсутнє markdown-посилання на template-файл ${rel}`)
|
|
141
|
+
}
|
|
142
|
+
if (reporter.getExitCode() !== 0) totalCode = 1
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
return totalCode
|
|
131
146
|
}
|
|
@@ -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
|
+
}
|