@7n/rules 1.3.0 → 1.3.1

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.1] - 2026-07-14
4
+
5
+ ### Changed
6
+
7
+ - docs(adr): merge pending ADR normalization batch
8
+
3
9
  ## [1.3.0] - 2026-07-14
4
10
 
5
11
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -25,7 +25,7 @@ const SNIPPET_EXTS = ['yml', 'yaml', 'json', 'jsonc']
25
25
  * Чи `needle` структурно вже присутній у якомусь елементі `actualArray`
26
26
  * (та сама subset-семантика, що й у детекторі — жодного окремого визначення "збігу").
27
27
  * @param {unknown[]} actualArray наявний масив
28
- * @param {unknown} needle елемент снippета, наявність якого перевіряємо
28
+ * @param {unknown} needle елемент snippet-а, наявність якого перевіряємо
29
29
  * @returns {boolean} true — вже присутній структурно
30
30
  */
31
31
  function containedIn(actualArray, needle) {
@@ -98,6 +98,57 @@ function mergeYamlDoc(doc, snippet, path) {
98
98
  doc.setIn(path, snippet) // leaf — перезаписуємо канонічним значенням
99
99
  }
100
100
 
101
+ /**
102
+ * Рахує наступний вміст JSON/JSONC-target. `null` — невалідний вхід (не чіпаємо);
103
+ * незмінений `prevText` — уже відповідає snippet-у (idempotent, без reformat).
104
+ * @param {string} prevText наявний вміст target-файлу
105
+ * @param {string} snippetPath абсолютний шлях до snippet-файлу
106
+ * @returns {string|null} новий вміст, незмінений `prevText`, або `null`
107
+ */
108
+ function computeJsonNextText(prevText, snippetPath) {
109
+ let snippet
110
+ let actual
111
+ try {
112
+ snippet = JSON.parse(readFileSync(snippetPath, 'utf8'))
113
+ actual = JSON.parse(prevText)
114
+ } catch {
115
+ return null // невалідний JSON — не чіпаємо детермінованим фіксом
116
+ }
117
+ // Уже відповідає snippet-у (та сама перевірка, що й детектор) → без reformat:
118
+ // JSON.stringify інакше переформатував би вже коректний файл (напр. компактний
119
+ // однорядковий snippet → pretty-print) без жодної реальної зміни.
120
+ if (checkSnippet(actual, snippet, { targetPath: '', source: '' }).length === 0) return prevText
121
+ return JSON.stringify(mergeJsonValue(actual, snippet), null, 2) + '\n'
122
+ }
123
+
124
+ /**
125
+ * Рахує наступний вміст YAML-target через `yaml` Document API. `null` — невалідний
126
+ * вхід; незмінений `prevText` — уже відповідає snippet-у (idempotent, без reformat).
127
+ * @param {string} prevText наявний вміст target-файлу
128
+ * @param {string} snippetPath абсолютний шлях до snippet-файлу
129
+ * @returns {Promise<string|null>} новий вміст, незмінений `prevText`, або `null`
130
+ */
131
+ async function computeYamlNextText(prevText, snippetPath) {
132
+ const { parse, parseDocument } = await import('yaml')
133
+ let snippet
134
+ let actualPlain
135
+ try {
136
+ snippet = parse(readFileSync(snippetPath, 'utf8'))
137
+ actualPlain = parse(prevText)
138
+ } catch {
139
+ return null // невалідний YAML — не чіпаємо детермінованим фіксом
140
+ }
141
+ // Уже відповідає snippet-у → без reformat: Document.toString() не завжди
142
+ // byte-identical на вже коректному вмісті (напр. folded block scalars).
143
+ if (checkSnippet(actualPlain, snippet, { targetPath: '', source: '' }).length === 0) return prevText
144
+ const doc = parseDocument(prevText)
145
+ if (doc.errors.length > 0) return null
146
+ if (snippet !== null && typeof snippet === 'object' && !Array.isArray(snippet)) {
147
+ for (const [k, v] of Object.entries(snippet)) mergeYamlDoc(doc, v, [k])
148
+ }
149
+ return doc.toString()
150
+ }
151
+
101
152
  /**
102
153
  * Створює T0-патерн, що приводить `targetPath` у відповідність `template/*.snippet.*`
103
154
  * свого concern-а (deep-merge, idempotent). Один writer — для будь-якого single-target
@@ -110,16 +161,13 @@ export function createTemplateFixPattern({ id, targetPath }) {
110
161
  id,
111
162
  test: violations => violations.some(v => v.file === targetPath),
112
163
  apply: async (violations, ctx) => {
113
- if (!violations.some(v => v.file === targetPath)) return { touchedFiles: [] }
164
+ if (violations.every(v => v.file !== targetPath)) return { touchedFiles: [] }
114
165
  if (!ctx.concernDir) return { touchedFiles: [] }
115
166
 
116
167
  const snippetPath = findSnippetFile(join(ctx.concernDir, 'template'), basename(targetPath))
117
168
  if (!snippetPath) return { touchedFiles: [] }
118
169
 
119
170
  const absTarget = join(ctx.cwd, targetPath)
120
- const ext = extname(snippetPath).toLowerCase()
121
- const isJson = ext === '.json' || ext === '.jsonc'
122
-
123
171
  const prevText = existsSync(absTarget) ? readFileSync(absTarget, 'utf8') : null
124
172
 
125
173
  // Файл відсутній → копіюємо snippet як є (без merge — немає з чим мерджити).
@@ -131,49 +179,12 @@ export function createTemplateFixPattern({ id, targetPath }) {
131
179
  return { touchedFiles: [absTarget], message: `${targetPath}: створено зі snippet` }
132
180
  }
133
181
 
134
- let nextText
135
- if (isJson) {
136
- let snippet
137
- let actual
138
- try {
139
- snippet = JSON.parse(readFileSync(snippetPath, 'utf8'))
140
- actual = JSON.parse(prevText)
141
- } catch {
142
- return { touchedFiles: [] } // невалідний JSON — не чіпаємо детермінованим фіксом
143
- }
144
- // Уже відповідає snippet-у (та сама перевірка, що й детектор) → не чіпаємо файл
145
- // взагалі: без цієї гейтки JSON.stringify переформатував би вже коректний файл
146
- // (напр. компактний однорядковий snippet → pretty-print) без жодної реальної зміни.
147
- if (checkSnippet(actual, snippet, { targetPath: '', source: '' }).length === 0) {
148
- return { touchedFiles: [] }
149
- }
150
- const merged = mergeJsonValue(actual, snippet)
151
- nextText = JSON.stringify(merged, null, 2) + '\n'
152
- } else {
153
- const { parse, parseDocument } = await import('yaml')
154
- let snippet
155
- let actualPlain
156
- try {
157
- snippet = parse(readFileSync(snippetPath, 'utf8'))
158
- actualPlain = parse(prevText)
159
- } catch {
160
- return { touchedFiles: [] } // невалідний YAML — не чіпаємо детермінованим фіксом
161
- }
162
- // Уже відповідає snippet-у (та сама перевірка, що й детектор) → не чіпаємо файл
163
- // взагалі: Document.toString() не завжди byte-identical на вже коректному вмісті
164
- // (напр. folded block scalars переформатовуються при round-trip без потреби).
165
- if (checkSnippet(actualPlain, snippet, { targetPath: '', source: '' }).length === 0) {
166
- return { touchedFiles: [] }
167
- }
168
- const doc = parseDocument(prevText)
169
- if (doc.errors.length > 0) return { touchedFiles: [] }
170
- if (snippet !== null && typeof snippet === 'object' && !Array.isArray(snippet)) {
171
- for (const [k, v] of Object.entries(snippet)) mergeYamlDoc(doc, v, [k])
172
- }
173
- nextText = doc.toString()
174
- }
182
+ const isJson = ['.json', '.jsonc'].includes(extname(snippetPath).toLowerCase())
183
+ const nextText = isJson
184
+ ? computeJsonNextText(prevText, snippetPath)
185
+ : await computeYamlNextText(prevText, snippetPath)
175
186
 
176
- if (nextText === prevText) return { touchedFiles: [] }
187
+ if (nextText === null || nextText === prevText) return { touchedFiles: [] }
177
188
  ctx.recordWrite?.(absTarget)
178
189
  writeFileSync(absTarget, nextText, 'utf8')
179
190
  return { touchedFiles: [absTarget], message: `${targetPath}: приведено у відповідність snippet` }