@nitra/cursor 1.13.31 → 1.13.38
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 +47 -1
- package/package.json +1 -1
- package/rules/changelog/fix/consistency/check.mjs +100 -85
- package/rules/ci4/ci4.mdc +7 -7
- package/rules/image-avif/image-avif.mdc +2 -2
- package/rules/js-lint/policy/vscode_extensions/template/extensions.json.snippet.json +1 -5
- package/rules/js-run/fix/runtime/check.mjs +3 -0
- package/rules/js-run/js-run.mdc +16 -1
- package/rules/js-run/policy/package_json/package_json.rego +17 -0
- package/rules/js-run/policy/package_json/template/package.json.deny.json +13 -1
- package/rules/k8s/fix/kubescape_exceptions/template/.kubescape-exceptions.json.snippet.json +21 -0
- package/rules/k8s/fix/manifests/check.mjs +775 -139
- package/rules/k8s/k8s.mdc +60 -6
- package/rules/k8s/lint/lint.mjs +29 -4
- package/rules/k8s/policy/base_kustomization/base_kustomization.rego +13 -6
- package/rules/k8s/policy/network_policy/network_policy.rego +158 -0
- package/rules/k8s/policy/network_policy/template/networkpolicy.snippet.yaml +32 -0
- package/rules/security/fix/trufflehog/check.mjs +3 -0
- package/rules/security/policy/package_json/template/package.json.snippet.json +5 -1
- package/rules/style-lint/style-lint.mdc +20 -1
- package/rules/text/policy/cspell/cspell.rego +1 -1
- package/rules/text/policy/markdownlint/markdownlint.rego +1 -1
- package/rules/text/policy/oxfmtrc/template/.oxfmtrc.json.snippet.json +1 -5
- package/rules/text/policy/vscode_extensions/template/extensions.json.snippet.json +1 -5
- package/rules/vue/vue.mdc +1 -0
- package/scripts/sync-claude-config.mjs +2 -2
- package/scripts/utils/check-mdc-template-refs.mjs +15 -5
- package/scripts/utils/inline-template-links.mjs +15 -8
- package/scripts/utils/package-manifest.mjs +24 -19
- package/scripts/utils/run-conftest-batch.mjs +22 -15
- package/scripts/utils/template.mjs +89 -21
|
@@ -2,10 +2,14 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { basename, extname, join } from 'node:path'
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const MD_LINK_RE = /\[([^\]]{1,200})\]\((\.\/[^)]{1,500})\)/g
|
|
6
|
+
const TEMPLATE_SEGMENT_RE = /\/template\//
|
|
6
7
|
const SLOTS = ['snippet', 'deny', 'contains']
|
|
7
8
|
|
|
8
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} filePath шлях до файлу
|
|
11
|
+
* @returns {string} назва мови для fenced-блока
|
|
12
|
+
*/
|
|
9
13
|
function langFromExt(filePath) {
|
|
10
14
|
const ext = extname(filePath)
|
|
11
15
|
if (ext === '.json') return 'json'
|
|
@@ -16,10 +20,13 @@ function langFromExt(filePath) {
|
|
|
16
20
|
|
|
17
21
|
// Strip `.<slot>.<ext>` suffix (slot ∈ snippet/deny/contains) to recover the
|
|
18
22
|
// real target file name (e.g. `package.json.snippet.json` → `package.json`).
|
|
19
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} fileBasename базове ім'я template-файлу
|
|
25
|
+
* @returns {string} ім'я реального target-файлу
|
|
26
|
+
*/
|
|
20
27
|
function normalizeTargetName(fileBasename) {
|
|
21
28
|
for (const slot of SLOTS) {
|
|
22
|
-
const m = fileBasename.match(new RegExp(`^(.+)
|
|
29
|
+
const m = fileBasename.match(new RegExp(String.raw`^(.+)\.${slot}\.[^.]+$`))
|
|
23
30
|
if (m) return m[1]
|
|
24
31
|
}
|
|
25
32
|
return fileBasename
|
|
@@ -29,19 +36,18 @@ function normalizeTargetName(fileBasename) {
|
|
|
29
36
|
* Finds markdown links whose path contains /template/ and replaces them with
|
|
30
37
|
* inline fenced blocks. Reads file from join(ruleDir, rel-path).
|
|
31
38
|
* Throws Error if a matched link target doesn't exist (fail loud — user must know).
|
|
32
|
-
*
|
|
33
39
|
* @param {string} text .mdc file contents
|
|
34
40
|
* @param {string} ruleDir absolute path to the rule directory (e.g. .../npm/rules/security/)
|
|
35
41
|
* @returns {Promise<string>} transformed text
|
|
36
42
|
*/
|
|
37
43
|
export async function inlineTemplateLinks(text, ruleDir) {
|
|
38
|
-
const matches = [...text.matchAll(
|
|
44
|
+
const matches = [...text.matchAll(MD_LINK_RE)].filter(m => TEMPLATE_SEGMENT_RE.test(m[2]))
|
|
39
45
|
if (matches.length === 0) return text
|
|
40
46
|
|
|
41
47
|
let result = text
|
|
42
48
|
for (const match of matches) {
|
|
43
49
|
const [fullMatch, , href] = match
|
|
44
|
-
// href starts with ./ and contains /template/
|
|
50
|
+
// href starts with ./ (regex) and contains /template/ (filter above)
|
|
45
51
|
const relPath = href.slice(2) // strip leading ./
|
|
46
52
|
const absPath = join(ruleDir, relPath)
|
|
47
53
|
|
|
@@ -49,7 +55,8 @@ export async function inlineTemplateLinks(text, ruleDir) {
|
|
|
49
55
|
throw new Error(`inlineTemplateLinks: file not found: ${absPath} (referenced from .mdc)`)
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
const
|
|
58
|
+
const raw = await readFile(absPath, 'utf8')
|
|
59
|
+
const contents = raw.trim()
|
|
53
60
|
const lang = langFromExt(absPath)
|
|
54
61
|
const targetName = normalizeTargetName(basename(absPath))
|
|
55
62
|
const replacement = `\`${targetName}\`:\n\n\`\`\`${lang}\n${contents}\n\`\`\``
|
|
@@ -10,16 +10,24 @@ import { parse as parseToml } from 'smol-toml'
|
|
|
10
10
|
|
|
11
11
|
import { getMonorepoPackageRootDirs } from './workspaces.mjs'
|
|
12
12
|
|
|
13
|
-
/** @typedef {'npm' | 'python'} PackageKind */
|
|
14
|
-
|
|
15
13
|
/**
|
|
14
|
+
@typedef {'npm' | 'python'} PackageKind
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
16
18
|
* @typedef {object} PackageManifest
|
|
17
|
-
|
|
19
|
+
|
|
20
|
+
* @property {PackageKind} kind поле
|
|
18
21
|
* @property {string} ws відносний шлях воркспейсу (`'.'` для кореня)
|
|
22
|
+
|
|
19
23
|
* @property {string} manifestRel `package.json` | `pyproject.toml`
|
|
24
|
+
|
|
20
25
|
* @property {string | null} name ім'я пакета (npm / PyPI)
|
|
26
|
+
|
|
21
27
|
* @property {string | null} version semver-рядок
|
|
28
|
+
|
|
22
29
|
* @property {boolean} registryPublishable чи застосовується режим порівняння з реєстром
|
|
30
|
+
|
|
23
31
|
* @property {string[] | null} [npmFiles] лише npm: `files` з package.json
|
|
24
32
|
*/
|
|
25
33
|
|
|
@@ -27,7 +35,7 @@ const PYPROJECT_GLOB_IGNORE = ['**/node_modules/**', '**/.git/**', '**/.venv/**'
|
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* @param {unknown} doc розпарсений pyproject.toml
|
|
30
|
-
* @returns {{ name: string | null, version: string | null }}
|
|
38
|
+
* @returns {{ name: string | null, version: string | null }} витягнуті поля project / tool.poetry
|
|
31
39
|
*/
|
|
32
40
|
function projectFieldsFromPyprojectDoc(doc) {
|
|
33
41
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
@@ -39,7 +47,7 @@ function projectFieldsFromPyprojectDoc(doc) {
|
|
|
39
47
|
const p = /** @type {Record<string, unknown>} */ (project)
|
|
40
48
|
return {
|
|
41
49
|
name: typeof p.name === 'string' ? p.name : null,
|
|
42
|
-
version: typeof p.version === 'string' ? p.version : null
|
|
50
|
+
version: typeof p.version === 'string' ? p.version : null
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
const tool = root.tool
|
|
@@ -49,7 +57,7 @@ function projectFieldsFromPyprojectDoc(doc) {
|
|
|
49
57
|
const po = /** @type {Record<string, unknown>} */ (poetry)
|
|
50
58
|
return {
|
|
51
59
|
name: typeof po.name === 'string' ? po.name : null,
|
|
52
|
-
version: typeof po.version === 'string' ? po.version : null
|
|
60
|
+
version: typeof po.version === 'string' ? po.version : null
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
63
|
}
|
|
@@ -58,7 +66,7 @@ function projectFieldsFromPyprojectDoc(doc) {
|
|
|
58
66
|
|
|
59
67
|
/**
|
|
60
68
|
* @param {string} text вміст pyproject.toml
|
|
61
|
-
* @returns {{ name: string | null, version: string | null }}
|
|
69
|
+
* @returns {{ name: string | null, version: string | null }} витягнуті поля project / tool.poetry
|
|
62
70
|
*/
|
|
63
71
|
export function parsePyprojectFields(text) {
|
|
64
72
|
try {
|
|
@@ -70,7 +78,7 @@ export function parsePyprojectFields(text) {
|
|
|
70
78
|
|
|
71
79
|
/**
|
|
72
80
|
* @param {string} ws шлях воркспейсу
|
|
73
|
-
* @returns {Promise<PackageManifest | null>}
|
|
81
|
+
* @returns {Promise<PackageManifest | null>} маніфест пакета або null
|
|
74
82
|
*/
|
|
75
83
|
export async function readPackageManifest(ws) {
|
|
76
84
|
const pkgPath = join(ws, 'package.json')
|
|
@@ -82,10 +90,7 @@ export async function readPackageManifest(ws) {
|
|
|
82
90
|
}
|
|
83
91
|
const pkg = /** @type {Record<string, unknown>} */ (parsed)
|
|
84
92
|
const registryPublishable =
|
|
85
|
-
typeof pkg.name === 'string' &&
|
|
86
|
-
pkg.name.length > 0 &&
|
|
87
|
-
pkg.private !== true &&
|
|
88
|
-
Array.isArray(pkg.files)
|
|
93
|
+
typeof pkg.name === 'string' && pkg.name.length > 0 && pkg.private !== true && Array.isArray(pkg.files)
|
|
89
94
|
return {
|
|
90
95
|
kind: 'npm',
|
|
91
96
|
ws,
|
|
@@ -93,7 +98,7 @@ export async function readPackageManifest(ws) {
|
|
|
93
98
|
name: typeof pkg.name === 'string' ? pkg.name : null,
|
|
94
99
|
version: typeof pkg.version === 'string' ? pkg.version : null,
|
|
95
100
|
registryPublishable,
|
|
96
|
-
npmFiles: Array.isArray(pkg.files) ? pkg.files : null
|
|
101
|
+
npmFiles: Array.isArray(pkg.files) ? pkg.files : null
|
|
97
102
|
}
|
|
98
103
|
} catch {
|
|
99
104
|
return null
|
|
@@ -113,14 +118,14 @@ export async function readPackageManifest(ws) {
|
|
|
113
118
|
name: fields.name,
|
|
114
119
|
version: fields.version,
|
|
115
120
|
registryPublishable,
|
|
116
|
-
npmFiles: null
|
|
121
|
+
npmFiles: null
|
|
117
122
|
}
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
/**
|
|
121
126
|
* Каталоги пакетів: npm (`package.json` / workspaces) + Python (`pyproject.toml` без package.json).
|
|
122
|
-
* @param {string} [repoRoot]
|
|
123
|
-
* @returns {Promise<string[]>}
|
|
127
|
+
* @param {string} [repoRoot] параметр
|
|
128
|
+
* @returns {Promise<string[]>} результат
|
|
124
129
|
*/
|
|
125
130
|
export async function getMonorepoProjectRootDirs(repoRoot = '.') {
|
|
126
131
|
const roots = new Set(await getMonorepoPackageRootDirs(repoRoot))
|
|
@@ -149,9 +154,9 @@ export async function getMonorepoProjectRootDirs(repoRoot = '.') {
|
|
|
149
154
|
|
|
150
155
|
/**
|
|
151
156
|
* Шлях до файлу маніфесту воркспейсу.
|
|
152
|
-
* @param {string} ws
|
|
153
|
-
* @param {PackageManifest} manifest
|
|
154
|
-
* @returns {string}
|
|
157
|
+
* @param {string} ws параметр
|
|
158
|
+
* @param {PackageManifest} manifest параметр
|
|
159
|
+
* @returns {string} результат
|
|
155
160
|
*/
|
|
156
161
|
export function manifestFilePath(ws, manifest) {
|
|
157
162
|
return join(ws, manifest.manifestRel)
|
|
@@ -22,7 +22,9 @@ import { fileURLToPath } from 'node:url'
|
|
|
22
22
|
|
|
23
23
|
import { resolveCmd } from './resolve-cmd.mjs'
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
Каталог пакета `@nitra/cursor`, від якого ресолвимо вшиті директорії правил.
|
|
27
|
+
*/
|
|
26
28
|
const PACKAGE_ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))))
|
|
27
29
|
|
|
28
30
|
/** Шлях до кореня правил. У npm-tarball публікується через `files: ["rules"]`. Кожне правило: `rules/<id>/policy/<name>/`. */
|
|
@@ -45,19 +47,27 @@ function failConftestMissing() {
|
|
|
45
47
|
)
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
/*
|
|
49
51
|
* @typedef {object} ConftestViolation
|
|
52
|
+
|
|
50
53
|
* @property {string} filename абсолютний шлях до файла, що дав порушення (з output conftest)
|
|
54
|
+
|
|
51
55
|
* @property {string} message текст порушення (як у `deny` rego-пакета)
|
|
56
|
+
|
|
52
57
|
* @property {string} namespace namespace rego-пакета (наприклад `abie.base_deployment_preem`)
|
|
53
58
|
*/
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
/*
|
|
56
61
|
* @typedef {object} ConftestBatchOptions
|
|
62
|
+
|
|
57
63
|
* @property {string} policyDirRel шлях до підкаталогу `npm/policy/...` (наприклад `abie/base_deployment_preem`)
|
|
64
|
+
|
|
58
65
|
* @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
|
|
66
|
+
|
|
59
67
|
* @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
|
|
68
|
+
|
|
60
69
|
* @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
|
|
70
|
+
|
|
61
71
|
* @property {object} [templateData] опціональне merged-дерево; серіалізується у JSON `{ "template": <data> }` і передається як `--data <tmpfile>` (cleanup після завершення)
|
|
62
72
|
*/
|
|
63
73
|
|
|
@@ -65,18 +75,11 @@ function failConftestMissing() {
|
|
|
65
75
|
* Pure args builder for conftest test. Extracted for unit-testability.
|
|
66
76
|
* Preserves the existing args layout (files before -p; --output json --no-color
|
|
67
77
|
* 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[]}
|
|
78
|
+
* @param {{ policyAbs: string, namespace: string, files: string[], extraArgs: string[], tmpDataFile: string|null }} p параметри батчу
|
|
79
|
+
* @returns {string[]} args для виклику conftest
|
|
70
80
|
*/
|
|
71
81
|
export function buildConftestArgs(p) {
|
|
72
|
-
const args = [
|
|
73
|
-
'test',
|
|
74
|
-
...p.files,
|
|
75
|
-
'-p',
|
|
76
|
-
p.policyAbs,
|
|
77
|
-
'--namespace',
|
|
78
|
-
p.namespace
|
|
79
|
-
]
|
|
82
|
+
const args = ['test', ...p.files, '-p', p.policyAbs, '--namespace', p.namespace]
|
|
80
83
|
if (p.tmpDataFile) args.push('--data', p.tmpDataFile)
|
|
81
84
|
args.push('--output', 'json', '--no-color', ...p.extraArgs)
|
|
82
85
|
return args
|
|
@@ -125,14 +128,18 @@ export function runConftestBatch(opts) {
|
|
|
125
128
|
if (result.status !== 0 && result.status !== 1) {
|
|
126
129
|
throw new Error(`conftest exit ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
|
|
127
130
|
}
|
|
128
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
@type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>}
|
|
133
|
+
*/
|
|
129
134
|
let parsed
|
|
130
135
|
try {
|
|
131
136
|
parsed = JSON.parse(result.stdout)
|
|
132
137
|
} catch {
|
|
133
138
|
throw new Error(`conftest stdout не парситься як JSON: ${(result.stdout || '').slice(0, 200)}`)
|
|
134
139
|
}
|
|
135
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
@type {ConftestViolation[]}
|
|
142
|
+
*/
|
|
136
143
|
const out = []
|
|
137
144
|
for (const entry of parsed) {
|
|
138
145
|
const failures = entry.failures ?? []
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Reads template/ for a concern directory and returns a merged structure indexed
|
|
3
3
|
* by target basename. For each <target>, returns whichever of snippet/deny/contains
|
|
4
4
|
* exist (parsed in native format by extension).
|
|
5
|
-
*
|
|
6
5
|
* @param {string} concernDir absolute path to fix/<concern>/ or policy/<concern>/
|
|
7
6
|
* @returns {Promise<Record<string, { snippet?: any, deny?: any, contains?: any }>>}
|
|
8
7
|
*/
|
|
@@ -13,8 +12,15 @@ import { basename as _basename, extname, join, relative } from 'node:path'
|
|
|
13
12
|
import { parse as parseToml } from 'smol-toml'
|
|
14
13
|
|
|
15
14
|
const SLOTS = ['snippet', 'deny', 'contains']
|
|
15
|
+
const IDENT_RE = /^[a-zA-Z_$][\w$]*$/
|
|
16
|
+
const NEWLINE_RE = /\r?\n/
|
|
17
|
+
const LEADING_BANG_RE = /^!/
|
|
16
18
|
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Parse file contents by extension; returns JS object for structured formats, string for text.
|
|
21
|
+
* @param {string} path шлях до файлу
|
|
22
|
+
* @returns {Promise<unknown>} розпарсений вміст
|
|
23
|
+
*/
|
|
18
24
|
async function parseByExt(path) {
|
|
19
25
|
const raw = await readFile(path, 'utf8')
|
|
20
26
|
const ext = extname(path).toLowerCase()
|
|
@@ -27,12 +33,21 @@ async function parseByExt(path) {
|
|
|
27
33
|
return raw // text-only
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} s сирий вміст JSON/JSONC
|
|
38
|
+
* @returns {string} текст без коментарів, рядкові літерали збережено
|
|
39
|
+
*/
|
|
30
40
|
function stripJsonComments(s) {
|
|
31
41
|
// Match string literals OR comments. Strings are returned unchanged so we never
|
|
32
42
|
// strip `/*` / `//` / `*/` that appear inside values (e.g. glob `**/node_modules/**`).
|
|
33
|
-
return s.
|
|
43
|
+
return s.replaceAll(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, m => (m.startsWith('"') ? m : ''))
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} dir каталог для рекурсивного обходу
|
|
48
|
+
* @param {string} [base] базовий шлях для відносних результатів
|
|
49
|
+
* @returns {Promise<string[]>} список відносних шляхів файлів
|
|
50
|
+
*/
|
|
36
51
|
async function walk(dir, base = dir) {
|
|
37
52
|
const out = []
|
|
38
53
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
@@ -46,23 +61,48 @@ async function walk(dir, base = dir) {
|
|
|
46
61
|
/**
|
|
47
62
|
* Parse "<target>.<slot>.<ext>" or "<target>" (text-only).
|
|
48
63
|
* Returns { target, slot } where slot is one of snippet|deny|contains|null (null = text-only target).
|
|
64
|
+
* @param {string} relPath відносний шлях template-файлу
|
|
65
|
+
* @returns {{ target: string, slot: string | null }} класифікація
|
|
49
66
|
*/
|
|
50
67
|
function classifyTemplateFile(relPath) {
|
|
51
68
|
// Try ".<slot>." suffix detection
|
|
52
69
|
for (const slot of SLOTS) {
|
|
53
|
-
const m = relPath.match(new RegExp(`^(?<target>.+)
|
|
70
|
+
const m = relPath.match(new RegExp(String.raw`^(?<target>.+)\.${slot}\.[^.]+$`))
|
|
54
71
|
if (m?.groups?.target) return { target: m.groups.target, slot }
|
|
55
72
|
}
|
|
56
73
|
// No slot suffix → text-only canon for the literal target name
|
|
57
74
|
return { target: relPath, slot: null }
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
/**
|
|
78
|
+
* @param {string|number} p сегмент шляху
|
|
79
|
+
* @returns {string} токен у форматі ідентифікатор / [n] / JSON-рядок
|
|
80
|
+
*/
|
|
81
|
+
function tokenizePathPart(p) {
|
|
82
|
+
if (typeof p === 'number') return `[${p}]`
|
|
83
|
+
if (IDENT_RE.test(p)) return p
|
|
84
|
+
return JSON.stringify(p)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {Array<string|number>} parts сегменти шляху
|
|
89
|
+
* @returns {string} дотовий шлях для повідомлень
|
|
90
|
+
*/
|
|
60
91
|
function formatPath(parts) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
92
|
+
const tokens = parts.map(p => tokenizePathPart(p))
|
|
93
|
+
let out = ''
|
|
94
|
+
for (const p of tokens) {
|
|
95
|
+
if (out === '') out = p
|
|
96
|
+
else if (p.startsWith('[')) out += p
|
|
97
|
+
else out += '.' + p
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
64
100
|
}
|
|
65
101
|
|
|
102
|
+
/**
|
|
103
|
+
* @param {unknown} v значення
|
|
104
|
+
* @returns {string} JSON-рядок для рядків, інакше String(v)
|
|
105
|
+
*/
|
|
66
106
|
function quote(v) {
|
|
67
107
|
return typeof v === 'string' ? JSON.stringify(v) : String(v)
|
|
68
108
|
}
|
|
@@ -71,9 +111,14 @@ function quote(v) {
|
|
|
71
111
|
* Deep subset-of check. Every leaf in `snippet` must equal same path in `actual`.
|
|
72
112
|
* Arrays in snippet: every element must be present in actual array.
|
|
73
113
|
* Returns array of violation messages.
|
|
114
|
+
* @param {unknown} actual фактичне значення з документа
|
|
115
|
+
* @param {unknown} snippet канонічний фрагмент із template
|
|
116
|
+
* @param {{ targetPath: string, source: string }} opts опції джерела
|
|
117
|
+
* @param {Array<string|number>} [path] поточний шлях у дереві
|
|
118
|
+
* @returns {string[]} список порушень
|
|
74
119
|
*/
|
|
75
120
|
export function checkSnippet(actual, snippet, opts, path = []) {
|
|
76
|
-
if (snippet
|
|
121
|
+
if (snippet === null || snippet === undefined) return []
|
|
77
122
|
const { targetPath, source } = opts
|
|
78
123
|
const violations = []
|
|
79
124
|
if (Array.isArray(snippet)) {
|
|
@@ -89,8 +134,8 @@ export function checkSnippet(actual, snippet, opts, path = []) {
|
|
|
89
134
|
}
|
|
90
135
|
return violations
|
|
91
136
|
}
|
|
92
|
-
if (
|
|
93
|
-
if (actual
|
|
137
|
+
if (typeof snippet === 'object') {
|
|
138
|
+
if (actual === null || actual === undefined || typeof actual !== 'object' || Array.isArray(actual)) {
|
|
94
139
|
violations.push(`${targetPath}: ${formatPath(path)} має бути об'єктом (${source})`)
|
|
95
140
|
return violations
|
|
96
141
|
}
|
|
@@ -109,11 +154,16 @@ export function checkSnippet(actual, snippet, opts, path = []) {
|
|
|
109
154
|
/**
|
|
110
155
|
* Walks deny tree; for any leaf path that exists in actual, returns violation
|
|
111
156
|
* with the deny's leaf string as reason.
|
|
157
|
+
* @param {unknown} actual фактичне значення з документа
|
|
158
|
+
* @param {unknown} deny дерево заборонених шляхів із template
|
|
159
|
+
* @param {{ targetPath: string, source: string }} opts опції джерела
|
|
160
|
+
* @param {Array<string|number>} [path] поточний шлях у дереві
|
|
161
|
+
* @returns {string[]} список порушень
|
|
112
162
|
*/
|
|
113
163
|
export function checkDeny(actual, deny, opts, path = []) {
|
|
114
|
-
if (deny
|
|
164
|
+
if (deny === null || deny === undefined) return []
|
|
115
165
|
const { targetPath, source } = opts
|
|
116
|
-
if (
|
|
166
|
+
if (typeof deny === 'object' && !Array.isArray(deny)) {
|
|
117
167
|
const out = []
|
|
118
168
|
for (const [k, v] of Object.entries(deny)) {
|
|
119
169
|
const childActual = actual && typeof actual === 'object' ? actual[k] : undefined
|
|
@@ -132,9 +182,14 @@ export function checkDeny(actual, deny, opts, path = []) {
|
|
|
132
182
|
/**
|
|
133
183
|
* For each leaf path that has an array of strings in `contains`, every string
|
|
134
184
|
* must appear as substring in the same path of `actual` (string leaf).
|
|
185
|
+
* @param {unknown} actual фактичне значення з документа
|
|
186
|
+
* @param {unknown} contains дерево обов'язкових підрядків із template
|
|
187
|
+
* @param {{ targetPath: string, source: string }} opts опції джерела
|
|
188
|
+
* @param {Array<string|number>} [path] поточний шлях у дереві
|
|
189
|
+
* @returns {string[]} список порушень
|
|
135
190
|
*/
|
|
136
191
|
export function checkContains(actual, contains, opts, path = []) {
|
|
137
|
-
if (contains
|
|
192
|
+
if (contains === null || contains === undefined) return []
|
|
138
193
|
const { targetPath, source } = opts
|
|
139
194
|
if (Array.isArray(contains)) {
|
|
140
195
|
const out = []
|
|
@@ -146,7 +201,7 @@ export function checkContains(actual, contains, opts, path = []) {
|
|
|
146
201
|
}
|
|
147
202
|
return out
|
|
148
203
|
}
|
|
149
|
-
if (
|
|
204
|
+
if (typeof contains === 'object') {
|
|
150
205
|
const out = []
|
|
151
206
|
for (const [k, v] of Object.entries(contains)) {
|
|
152
207
|
const childActual = actual && typeof actual === 'object' ? actual[k] : undefined
|
|
@@ -160,13 +215,21 @@ export function checkContains(actual, contains, opts, path = []) {
|
|
|
160
215
|
/**
|
|
161
216
|
* For text-only targets (e.g. .stylelintignore): every non-empty, non-comment
|
|
162
217
|
* line in `template` must appear (trimmed) in `actual`.
|
|
218
|
+
* @param {unknown} actual фактичний текст документа
|
|
219
|
+
* @param {unknown} template канонічний текст із template
|
|
220
|
+
* @param {{ targetPath: string, source: string }} opts опції джерела
|
|
221
|
+
* @returns {string[]} список порушень
|
|
163
222
|
*/
|
|
164
223
|
export function checkTextSubset(actual, template, opts) {
|
|
165
|
-
if (template
|
|
224
|
+
if (template === null || template === undefined) return []
|
|
166
225
|
const { targetPath, source } = opts
|
|
167
|
-
const actualLines = new Set(
|
|
226
|
+
const actualLines = new Set(
|
|
227
|
+
String(actual ?? '')
|
|
228
|
+
.split(NEWLINE_RE)
|
|
229
|
+
.map(l => l.trim())
|
|
230
|
+
)
|
|
168
231
|
const out = []
|
|
169
|
-
for (const raw of String(template).split(
|
|
232
|
+
for (const raw of String(template).split(NEWLINE_RE)) {
|
|
170
233
|
const line = raw.trim()
|
|
171
234
|
if (line === '' || line.startsWith('#')) continue
|
|
172
235
|
if (!actualLines.has(line)) {
|
|
@@ -176,17 +239,23 @@ export function checkTextSubset(actual, template, opts) {
|
|
|
176
239
|
return out
|
|
177
240
|
}
|
|
178
241
|
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} concernDir абсолютний шлях до fix/<concern>/ або policy/<concern>/
|
|
244
|
+
* @returns {Promise<Record<string, { snippet?: any, deny?: any, contains?: any }>>} merged template-дерево, індексоване за target
|
|
245
|
+
*/
|
|
179
246
|
export async function loadTemplate(concernDir) {
|
|
180
247
|
const tplDir = join(concernDir, 'template')
|
|
181
248
|
if (!existsSync(tplDir)) return {}
|
|
182
|
-
|
|
249
|
+
const tplStat = await stat(tplDir)
|
|
250
|
+
if (!tplStat.isDirectory()) return {}
|
|
183
251
|
const files = await walk(tplDir)
|
|
184
252
|
const result = {}
|
|
185
253
|
for (const rel of files) {
|
|
186
254
|
const { target, slot } = classifyTemplateFile(rel)
|
|
187
255
|
if (!result[target]) result[target] = {}
|
|
188
256
|
const value = await parseByExt(join(tplDir, rel))
|
|
189
|
-
if (slot === null)
|
|
257
|
+
if (slot === null)
|
|
258
|
+
result[target].snippet = value // text-only treated as snippet
|
|
190
259
|
else result[target][slot] = value
|
|
191
260
|
}
|
|
192
261
|
return result
|
|
@@ -204,7 +273,7 @@ export async function resolveConcernTemplateData(concernAbsDir, targetJson) {
|
|
|
204
273
|
const single = targetJson?.files?.single
|
|
205
274
|
if (single) return tpl[_basename(single)]
|
|
206
275
|
const glob = targetJson?.files?.walkGlob
|
|
207
|
-
if (typeof glob === 'string') return tpl[_basename(glob.replace(
|
|
276
|
+
if (typeof glob === 'string') return tpl[_basename(glob.replace(LEADING_BANG_RE, ''))]
|
|
208
277
|
if (Array.isArray(glob)) {
|
|
209
278
|
for (const g of glob) {
|
|
210
279
|
if (g.startsWith('!')) continue
|
|
@@ -212,5 +281,4 @@ export async function resolveConcernTemplateData(concernAbsDir, targetJson) {
|
|
|
212
281
|
if (data) return data
|
|
213
282
|
}
|
|
214
283
|
}
|
|
215
|
-
return undefined
|
|
216
284
|
}
|