@nitra/cursor 3.10.0 → 3.12.0

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.
@@ -7,12 +7,20 @@
7
7
  * FS-доступ (`readdir`/`readFile`/`exists`) ін'єктується — тестується без диска.
8
8
  */
9
9
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
10
- import { join } from 'node:path'
10
+ import { dirname, join } from 'node:path'
11
11
  import { cwd as processCwd } from 'node:process'
12
12
 
13
- /** Поля-лінки у front-matter, що утворюють ланцюг. */
13
+ /** Поля-лінки у front-matter (порядок відображення). */
14
14
  const LINK_FIELDS = ['adr', 'spec', 'plan', 'flow', 'change', 'task']
15
15
 
16
+ /**
17
+ * Інформаційні лінк-поля: показуються, але їх відсутність НЕ є розривом ланцюга.
18
+ * `flow` вказує на runtime-стан `.worktrees/<branch>.flow.json` — gitignored, поза
19
+ * `docs/`, існує лише під час задачі; у чистому checkout/CI його нема ніколи, тож
20
+ * рахувати його розривом — хибний сигнал. Решта полів — ланки ланцюга (breaking).
21
+ */
22
+ const INFO_LINK_FIELDS = new Set(['flow'])
23
+
16
24
  /** Каталоги з traceable-артефактами. */
17
25
  const DIRS = ['docs/tasks', 'docs/specs', 'docs/plans', 'docs/adr']
18
26
 
@@ -52,20 +60,40 @@ function isSimpleKey(key) {
52
60
 
53
61
  /**
54
62
  * Будує аналіз: для кожного артефакту — його лінки зі статусом ok/розрив.
63
+ * `breaking` — чи відсутність цього лінка рве ланцюг (chain-поля) чи лише
64
+ * інформаційна (`flow` → runtime-стан).
55
65
  * @param {{ file: string, fm: Record<string, string | null> }[]} artifacts артефакти з front-matter
56
- * @param {(target: string) => boolean} exists чи існує цільовий файл лінка
57
- * @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean }[] }[]} аналіз
66
+ * @param {(target: string, artifactFile: string) => boolean} resolve чи резолвиться лінк (відносно артефакту/кореня)
67
+ * @returns {{ file: string, kind: string | null, id: string | null, status: string | null, links: { field: string, target: string, ok: boolean, breaking: boolean }[] }[]} аналіз
58
68
  */
59
- export function analyze(artifacts, exists) {
69
+ export function analyze(artifacts, resolve) {
60
70
  return artifacts.map(({ file, fm }) => ({
61
71
  file,
62
72
  kind: fm.kind ?? null,
63
73
  id: fm.id ?? null,
64
74
  status: fm.status ?? null,
65
- links: LINK_FIELDS.filter(f => fm[f]).map(f => ({ field: f, target: fm[f], ok: exists(fm[f]) }))
75
+ links: LINK_FIELDS.filter(f => fm[f]).map(f => ({
76
+ field: f,
77
+ target: fm[f],
78
+ ok: resolve(fm[f], file),
79
+ breaking: !INFO_LINK_FIELDS.has(f)
80
+ }))
66
81
  }))
67
82
  }
68
83
 
84
+ /**
85
+ * Чи резолвиться лінк: спершу відносно теки артефакту (конвенція доків `../specs/…`),
86
+ * далі fallback на root-relative (`docs/specs/…`). Обидві форми вважаються валідними.
87
+ * @param {string} root корінь репо
88
+ * @param {string} artifactFile rel-шлях артефакту (напр. `docs/plans/x.md`)
89
+ * @param {string} target значення лінка
90
+ * @param {(absPath: string) => boolean} exists перевірка існування
91
+ * @returns {boolean} чи знайдено цільовий файл
92
+ */
93
+ function resolveLink(root, artifactFile, target, exists) {
94
+ return exists(join(root, dirname(artifactFile), target)) || exists(join(root, target))
95
+ }
96
+
69
97
  /**
70
98
  * Текстовий рендер аналізу.
71
99
  * @param {object[]} analysis результат `analyze`
@@ -77,14 +105,24 @@ export function render(analysis) {
77
105
  for (const a of analysis) {
78
106
  lines.push(`${a.kind ?? '?'} · ${a.id ?? a.file} [${a.status ?? '—'}]`)
79
107
  for (const l of a.links) {
80
- const mark = l.ok ? '→' : '✗'
81
- const note = l.ok ? '' : ' (РОЗРИВ — файл відсутній)'
82
- lines.push(` ${mark} ${l.field}: ${l.target}${note}`)
108
+ lines.push(` ${renderLink(l)}`)
83
109
  }
84
110
  }
85
111
  return lines.join('\n')
86
112
  }
87
113
 
114
+ /**
115
+ * Рядок одного лінка: `→` ok; `✗ … (РОЗРИВ)` — нерезолвлене chain-поле;
116
+ * `~ … (не рве ланцюг)` — нерезолвлене інформаційне поле (`flow`, runtime-стан).
117
+ * @param {{ field: string, target: string, ok: boolean, breaking: boolean }} l лінк
118
+ * @returns {string} рядок
119
+ */
120
+ function renderLink(l) {
121
+ if (l.ok) return `→ ${l.field}: ${l.target}`
122
+ if (l.breaking) return `✗ ${l.field}: ${l.target} (РОЗРИВ — файл відсутній)`
123
+ return `~ ${l.field}: ${l.target} (runtime-стан — не рве ланцюг)`
124
+ }
125
+
88
126
  /**
89
127
  * CLI `n-cursor trace [--json]`. Повертає 1, якщо є розриви ланцюга.
90
128
  * @param {string[]} args аргументи
@@ -108,7 +146,8 @@ export function runTraceCli(args, deps = {}) {
108
146
  }
109
147
  }
110
148
 
111
- const analysis = analyze(artifacts, target => exists(join(root, target)))
149
+ const analysis = analyze(artifacts, (target, file) => resolveLink(root, file, target, exists))
112
150
  log(args.includes('--json') ? JSON.stringify(analysis, null, 2) : render(analysis))
113
- return analysis.some(a => a.links.some(l => !l.ok)) ? 1 : 0
151
+ // Розрив ланцюга лише нерезолвлене chain-поле; нерезолвлений `flow` (runtime-стан) не рахуємо.
152
+ return analysis.some(a => a.links.some(l => l.breaking && !l.ok)) ? 1 : 0
114
153
  }
@@ -28,3 +28,31 @@ export function collectChangedFiles(cwd = process.cwd()) {
28
28
  const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
29
29
  return [...new Set([...modified, ...untracked])]
30
30
  }
31
+
32
+ /**
33
+ * Список змінених + untracked файлів **відносно базового комміту**.
34
+ *
35
+ * `git diff <base>` (без `..`/`...`, без `HEAD`) порівнює base-комміт із поточним
36
+ * **робочим деревом** — тобто однаково ловить і закомічене від base, і staged, і
37
+ * незакомічені модифікації. Це гарантує однакову поведінку незалежно від того, чи
38
+ * зміни вже закомічені у worktree (потрібно для flow-турнікета, де executor комітить
39
+ * кожен крок). Без `base` — fallback на `collectChangedFiles` (робоче дерево vs HEAD).
40
+ * @param {string|null} [base] базовий комміт (`metadata.base_commit` зі стану flow)
41
+ * @param {string} [cwd] корінь репо
42
+ * @returns {string[]} унікальні шляхи (без видалених)
43
+ */
44
+ export function collectChangedFilesSince(base, cwd = process.cwd()) {
45
+ if (!base) return collectChangedFiles(cwd)
46
+ // Fail-closed: недосяжний base (rebase/force-update/shallow prune) інакше дав би `git diff`
47
+ // exit 128 → порожній список → gate мовчки пройшов би без перевірки. Краще явна помилка.
48
+ const verify = spawnSync('git', ['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd, encoding: 'utf8' })
49
+ if (verify.status !== 0 || verify.error) {
50
+ throw new Error(
51
+ `collectChangedFilesSince: base-комміт «${base}» недосяжний у ${cwd} ` +
52
+ '(rebase/force-update?) — coverage --changed не може визначити scope'
53
+ )
54
+ }
55
+ const changed = gitLines(['diff', base, '--name-only', '--diff-filter=ACMR'], cwd)
56
+ const untracked = gitLines(['ls-files', '--others', '--exclude-standard'], cwd)
57
+ return [...new Set([...changed, ...untracked])]
58
+ }