@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/package.json +1 -1
  3. package/rules/changelog/fix/consistency/check.mjs +100 -85
  4. package/rules/ci4/ci4.mdc +7 -7
  5. package/rules/image-avif/image-avif.mdc +2 -2
  6. package/rules/js-lint/policy/vscode_extensions/template/extensions.json.snippet.json +1 -5
  7. package/rules/js-run/fix/runtime/check.mjs +3 -0
  8. package/rules/js-run/js-run.mdc +16 -1
  9. package/rules/js-run/policy/package_json/package_json.rego +17 -0
  10. package/rules/js-run/policy/package_json/template/package.json.deny.json +13 -1
  11. package/rules/k8s/fix/kubescape_exceptions/template/.kubescape-exceptions.json.snippet.json +21 -0
  12. package/rules/k8s/fix/manifests/check.mjs +775 -139
  13. package/rules/k8s/k8s.mdc +60 -6
  14. package/rules/k8s/lint/lint.mjs +29 -4
  15. package/rules/k8s/policy/base_kustomization/base_kustomization.rego +13 -6
  16. package/rules/k8s/policy/network_policy/network_policy.rego +158 -0
  17. package/rules/k8s/policy/network_policy/template/networkpolicy.snippet.yaml +32 -0
  18. package/rules/security/fix/trufflehog/check.mjs +3 -0
  19. package/rules/security/policy/package_json/template/package.json.snippet.json +5 -1
  20. package/rules/style-lint/style-lint.mdc +20 -1
  21. package/rules/text/policy/cspell/cspell.rego +1 -1
  22. package/rules/text/policy/markdownlint/markdownlint.rego +1 -1
  23. package/rules/text/policy/oxfmtrc/template/.oxfmtrc.json.snippet.json +1 -5
  24. package/rules/text/policy/vscode_extensions/template/extensions.json.snippet.json +1 -5
  25. package/rules/vue/vue.mdc +1 -0
  26. package/scripts/sync-claude-config.mjs +2 -2
  27. package/scripts/utils/check-mdc-template-refs.mjs +15 -5
  28. package/scripts/utils/inline-template-links.mjs +15 -8
  29. package/scripts/utils/package-manifest.mjs +24 -19
  30. package/scripts/utils/run-conftest-batch.mjs +22 -15
  31. 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 TEMPLATE_LINK_RE = /\[([^\]]+)\]\((\.\/[^)]*\/template\/[^)]+)\)/g
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
- /** @param {string} filePath */
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
- /** @param {string} fileBasename */
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(`^(.+)\\.${slot}\\.[^.]+$`))
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(TEMPLATE_LINK_RE)]
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/ already guaranteed by regex
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 contents = (await readFile(absPath, 'utf8')).trim()
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
- * @property {PackageKind} kind
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
- /** Каталог пакета `@nitra/cursor`, від якого ресолвимо вшиті директорії правил. */
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
- /** @type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>} */
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
- /** @type {ConftestViolation[]} */
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
- /** Parse file contents by extension; returns JS object for structured formats, string for text. */
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.replace(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, m => (m.startsWith('"') ? m : ''))
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>.+)\\.${slot}\\.[^.]+$`))
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
- return parts
62
- .map(p => (typeof p === 'number' ? `[${p}]` : /^[a-zA-Z_$][\w$]*$/.test(p) ? p : JSON.stringify(p)))
63
- .reduce((acc, p) => (acc === '' ? p : p.startsWith('[') ? acc + p : acc + '.' + p), '')
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 == null) return []
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 (snippet !== null && typeof snippet === 'object') {
93
- if (actual == null || typeof actual !== 'object' || Array.isArray(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 == null) return []
164
+ if (deny === null || deny === undefined) return []
115
165
  const { targetPath, source } = opts
116
- if (deny !== null && typeof deny === 'object' && !Array.isArray(deny)) {
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 == null) return []
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 (contains !== null && typeof contains === 'object') {
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 == null) return []
224
+ if (template === null || template === undefined) return []
166
225
  const { targetPath, source } = opts
167
- const actualLines = new Set(String(actual ?? '').split(/\r?\n/).map(l => l.trim()))
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(/\r?\n/)) {
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
- if (!(await stat(tplDir)).isDirectory()) return {}
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) result[target].snippet = value // text-only treated as snippet
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
  }