@7n/rules 1.85.0 → 1.86.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.86.1] - 2026-08-05
4
+
5
+ ### Fixed
6
+
7
+ - оновлено pin `@7n/llm-lib` до 3.0.0 (автосинк релізного трейна)
8
+
9
+ ## [1.86.0] - 2026-08-05
10
+
11
+ ### Added
12
+
13
+ - Правило security: новий концерн tracked_symlink — repo-wide detector відстежених git-ом symlink-ів, що вказують за межі репозиторію (абсолютний шлях або відносний з виходом вгору за корінь). Ловить рецидив кшталту target -> /private/tmp/... (коміт 867ff7b3): у чужому клоні такий symlink робить збірку й тести недетермінованими, а .gitignore не рятує, бо ignore не покриває вже відстежені шляхи. Фіксу немає — detector друкує готову команду git rm --cached.
14
+
3
15
  ## [1.85.0] - 2026-08-05
4
16
 
5
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.85.0",
3
+ "version": "1.86.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -66,7 +66,7 @@
66
66
  "rename-yaml-extensions": "bun ./bin/n-rules.js rename-yaml-extensions"
67
67
  },
68
68
  "dependencies": {
69
- "@7n/llm-lib": "2.14.17",
69
+ "@7n/llm-lib": "3.0.0",
70
70
  "ajv": "^8.20.0",
71
71
  "cli-progress": "^3.12.0",
72
72
  "github-actionlint": "^1.7.12",
@@ -83,9 +83,9 @@
83
83
  "zod": "^4.4.3"
84
84
  },
85
85
  "optionalDependencies": {
86
- "@7n/rules-darwin-arm64": "1.84.0",
87
- "@7n/rules-linux-x64": "1.84.0",
88
- "@7n/rules-win32-x64": "1.84.0",
86
+ "@7n/rules-darwin-arm64": "1.86.1",
87
+ "@7n/rules-linux-x64": "1.86.1",
88
+ "@7n/rules-win32-x64": "1.86.1",
89
89
  "@earendil-works/pi-ai": "0.80.10",
90
90
  "@earendil-works/pi-coding-agent": "0.80.10"
91
91
  },
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "https://unpkg.com/@7n/rules/schemas/concern.json",
3
+ "lint": {
4
+ "scope": "full"
5
+ }
6
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * lint-поверхня security/tracked_symlink: read-only detector symlink-ів, що відстежуються
3
+ * git-ом і вказують ЗА МЕЖІ репозиторію — абсолютним шляхом (`/private/tmp/…`, `C:\…`)
4
+ * або відносним, що виходить вгору за корінь (`../../…`).
5
+ *
6
+ * Навіщо. Такий symlink несе шлях, специфічний для машини автора, у всі клони: у чужому
7
+ * checkout-і збірка пише артефакти кудись у чужу теку (або нікуди), а тести, що шукають
8
+ * артефакти за `<repo>/<path>/…`, стають недетермінованими. Класичний випадок цього репо —
9
+ * коміт 867ff7b3, який приніс `target -> /private/tmp/claude-501/cargo-target-…`;
10
+ * `.gitignore` не рятує, бо ignore не покриває вже відстежені шляхи.
11
+ *
12
+ * Фіксу немає (авто-правка symlink-а — здогадка про намір автора): detector лише сигналить
13
+ * і друкує готову команду прибирання з індексу.
14
+ */
15
+ import { posix } from 'node:path'
16
+
17
+ import { createViolationReporter } from '../../../scripts/lib/lint-surface/violation-reporter.mjs'
18
+ import { resolveCmd } from '../../../scripts/utils/resolve-cmd.mjs'
19
+ import { spawnAsync } from '../../../scripts/utils/spawn-async.mjs'
20
+
21
+ /** Режим symlink-запису в git-індексі (`git ls-files -s`). */
22
+ const SYMLINK_MODE = '120000'
23
+
24
+ /** `<mode> <sha> <stage>\t<path>` — рядок `git ls-files -s -z` (роздільник записів `\0`). */
25
+ const LS_FILES_ENTRY_RE = /^(?<mode>\d{6}) (?<sha>[0-9a-f]+) (?<stage>\d)\t(?<path>.+)$/su
26
+
27
+ /** Windows-шлях із літерою диска (`C:\…`, `C:/…`) — теж абсолютний, хоч `isAbsolute` на POSIX так не вважає. */
28
+ const WINDOWS_DRIVE_RE = /^[a-z]:[/\\]/iu
29
+
30
+ /**
31
+ * Чи є ціль symlink-а абсолютним шляхом у будь-якій із двох конвенцій.
32
+ * Перевірка суто текстова (без доступу до ФС) — щоб вердикт не залежав від
33
+ * машини, на якій виконується lint.
34
+ * @param {string} target ціль symlink-а, як вона записана в git-індексі
35
+ * @returns {boolean} true, якщо ціль абсолютна
36
+ */
37
+ function isAbsoluteTarget(target) {
38
+ return posix.isAbsolute(target) || WINDOWS_DRIVE_RE.test(target)
39
+ }
40
+
41
+ /**
42
+ * Чи виходить відносна ціль symlink-а за корінь репозиторію.
43
+ * Рахується арифметикою шляхів від теки самого symlink-а — без `realpath`, тож
44
+ * результат однаковий у будь-якому checkout-і.
45
+ * @param {string} linkPath posix-relative шлях symlink-а від кореня репо
46
+ * @param {string} target ціль symlink-а з git-індексу (відносний шлях)
47
+ * @returns {boolean} true, якщо ціль лежить поза деревом репо
48
+ */
49
+ function escapesRepoRoot(linkPath, target) {
50
+ // Корінь навмисно синтетичний: обчислення не торкається ФС, тож той самий symlink
51
+ // дає той самий вердикт на будь-якій машині й у будь-якому checkout-і.
52
+ const root = '/repo'
53
+ const resolved = posix.resolve(root, posix.dirname(linkPath), target)
54
+ return resolved !== root && !resolved.startsWith(`${root}/`)
55
+ }
56
+
57
+ /**
58
+ * Розбирає `\0`-розділений вивід `git ls-files -s -z` у symlink-записи.
59
+ * @param {string} stdout сирий stdout `git ls-files -s -z`
60
+ * @returns {{ sha: string, path: string }[]} лише записи з режимом 120000
61
+ */
62
+ function parseSymlinkEntries(stdout) {
63
+ /** @type {{ sha: string, path: string }[]} */
64
+ const entries = []
65
+ for (const record of stdout.split('\0')) {
66
+ if (record.length === 0) continue
67
+ const groups = LS_FILES_ENTRY_RE.exec(record)?.groups
68
+ if (!groups || groups.mode !== SYMLINK_MODE) continue
69
+ entries.push({ sha: groups.sha, path: posix.normalize(groups.path) })
70
+ }
71
+ return entries
72
+ }
73
+
74
+ /**
75
+ * Detector security/tracked_symlink: жоден відстежений symlink не має вказувати за межі репо.
76
+ * Поза git-робочим деревом (немає `git` у PATH або каталог не є репо) перевірка пропускається —
77
+ * її предмет, індекс git, там просто відсутній.
78
+ * @param {import('../../../scripts/lib/lint-surface/types.mjs').LintContext} ctx контекст lint-прогону
79
+ * @returns {Promise<import('../../../scripts/lib/lint-surface/types.mjs').LintResult>} результат detector-а
80
+ */
81
+ export async function lint(ctx) {
82
+ const reporter = createViolationReporter(ctx)
83
+ const { fail } = reporter
84
+ const cwd = ctx.cwd
85
+
86
+ const git = resolveCmd('git')
87
+ if (!git) {
88
+ return {
89
+ violations: [],
90
+ diagnostics: [
91
+ { level: 'info', message: 'security/tracked_symlink: `git` не знайдено в PATH — перевірку пропущено' }
92
+ ]
93
+ }
94
+ }
95
+
96
+ const listed = await spawnAsync(git, ['ls-files', '-s', '-z'], { cwd, signal: ctx.signal })
97
+ if (listed.exitCode !== 0) {
98
+ return {
99
+ violations: [],
100
+ diagnostics: [{ level: 'info', message: 'security/tracked_symlink: не git-робоче дерево — перевірку пропущено' }]
101
+ }
102
+ }
103
+
104
+ const entries = parseSymlinkEntries(listed.stdout)
105
+ for (const entry of entries) {
106
+ // Ціль читається з git-індексу, а не через readlink робочого дерева: перевіряємо
107
+ // саме те, що поїде у чужий клон. Symlink-ів у здоровому дереві одиниці, тож
108
+ // послідовний cat-file на запис дешевший за розбір бінарного `cat-file --batch`.
109
+ const blob = await spawnAsync(git, ['cat-file', 'blob', entry.sha], { cwd, signal: ctx.signal })
110
+ if (blob.exitCode !== 0) continue
111
+ const target = blob.stdout.replace(/\n+$/u, '')
112
+ if (target.length === 0) continue
113
+
114
+ const absolute = isAbsoluteTarget(target)
115
+ if (!absolute && !escapesRepoRoot(entry.path, target)) continue
116
+
117
+ const kind = absolute ? 'абсолютний шлях' : 'шлях за межами репо'
118
+ fail(
119
+ `security/tracked_symlink: відстежений symlink \`${entry.path}\` → \`${target}\` (${kind}) — ` +
120
+ 'у чужому клоні він указує в неіснуючу або чужу теку, роблячи збірку й тести недетермінованими. ' +
121
+ `Прибери його з індексу: \`git rm --cached ${entry.path}\` і додай \`${entry.path}\` у .gitignore ` +
122
+ '(патерн БЕЗ кінцевого слеша — git не ходить за symlink-ом і бачить його як file-entry)',
123
+ { reason: absolute ? 'absolute-target' : 'escapes-repo', file: entry.path, data: { target } }
124
+ )
125
+ }
126
+
127
+ return reporter.result()
128
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "lang-js": {
3
+ "file": "plugin-lang-js.wasm",
4
+ "sha256": "04b134d49b3ec5409e1fcdb17765fe50623acdea1396f4e7abf47f4b96bd9701"
5
+ }
6
+ }
Binary file