@nitra/cursor 1.17.0 → 1.17.2
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 +21 -0
- package/bin/n-cursor.js +16 -16
- package/package.json +2 -1
- package/rules/abie/fix.mjs +3 -3
- package/rules/adr/fix.mjs +3 -3
- package/rules/adr/js/hooks.mjs +6 -6
- package/rules/bun/fix.mjs +3 -3
- package/rules/capacitor/fix.mjs +3 -3
- package/rules/changelog/fix.mjs +3 -3
- package/rules/changelog/js/consistency.mjs +15 -15
- package/rules/ci4/fix.mjs +3 -3
- package/rules/docker/fix.mjs +3 -3
- package/rules/efes/fix.mjs +3 -3
- package/rules/feedback/fix.mjs +3 -3
- package/rules/ga/fix.mjs +3 -3
- package/rules/graphql/fix.mjs +3 -3
- package/rules/hasura/fix.mjs +3 -3
- package/rules/image-avif/fix.mjs +3 -3
- package/rules/image-compress/fix.mjs +3 -3
- package/rules/js-bun-db/fix.mjs +3 -3
- package/rules/js-bun-redis/fix.mjs +3 -3
- package/rules/js-lint/coverage/coverage.mjs +26 -36
- package/rules/js-lint/fix.mjs +3 -3
- package/rules/js-mssql/fix.mjs +3 -3
- package/rules/js-run/fix.mjs +3 -3
- package/rules/k8s/fix.mjs +3 -3
- package/rules/nginx-default-tpl/fix.mjs +3 -3
- package/rules/npm-module/fix.mjs +3 -3
- package/rules/php/fix.mjs +3 -3
- package/rules/rego/fix.mjs +3 -3
- package/rules/rust/coverage/coverage.mjs +22 -43
- package/rules/rust/fix.mjs +3 -3
- package/rules/rust/lib/has-cargo-toml.mjs +1 -3
- package/rules/security/fix.mjs +3 -3
- package/rules/style-lint/fix.mjs +3 -3
- package/rules/style-lint/js/tooling.mjs +1 -1
- package/rules/tauri/fix.mjs +3 -3
- package/rules/test/coverage/coverage.mjs +27 -25
- package/rules/test/fix.mjs +3 -3
- package/rules/test/js/cargo_mutants_config.mjs +65 -0
- package/rules/test/js/data/cargo_mutants_config/mutants.toml.baseline +4 -0
- package/rules/test/js/data/stryker_config/stryker.config.baseline.mjs +12 -0
- package/rules/test/js/location.mjs +1 -1
- package/rules/test/js/stryker_config.mjs +61 -0
- package/rules/test/test.mdc +16 -3
- package/rules/text/fix.mjs +3 -3
- package/rules/vue/fix.mjs +3 -3
- package/scripts/lib/run-rule-cli.mjs +11 -0
- package/scripts/lib/run-standard-rule.mjs +1 -1
- package/scripts/utils/resolve-cargo-manifest.mjs +62 -0
- package/scripts/utils/resolve-js-root.mjs +46 -0
- package/scripts/utils/with-lock.mjs +27 -16
- package/scripts/utils/worktree-fingerprint.mjs +10 -7
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Концерн `cargo_mutants_config` правила test (test.mdc): якщо `rust` присутнє
|
|
3
|
+
* в `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ Cargo.toml
|
|
4
|
+
* (cwd і всі workspaces, з підтримкою Tauri-патерну) і копіює canonical
|
|
5
|
+
* baseline `.cargo/mutants.toml` у каталог кожного manifest'а, якщо файлу немає.
|
|
6
|
+
*
|
|
7
|
+
* Self-gating: концерн silently skips коли `rust` не enabled.
|
|
8
|
+
* Якщо `rust` enabled, але жодного Cargo.toml не знайдено — теж silently skip
|
|
9
|
+
* (manifest може з'явитися пізніше; це не помилка).
|
|
10
|
+
*
|
|
11
|
+
* Baseline — порожній файл з коментом; cargo-mutants має робочі defaults.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { copyFile, mkdir } from 'node:fs/promises'
|
|
15
|
+
import { dirname, join, relative } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
19
|
+
import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
20
|
+
import { resolveAllCargoManifests } from '../../../scripts/utils/resolve-cargo-manifest.mjs'
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const BASELINE_PATH = join(HERE, 'data', 'cargo_mutants_config', 'mutants.toml.baseline')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
|
|
27
|
+
*/
|
|
28
|
+
export async function check() {
|
|
29
|
+
const reporter = createCheckReporter()
|
|
30
|
+
const cwd = process.cwd()
|
|
31
|
+
const config = await readNCursorConfigLite(cwd)
|
|
32
|
+
|
|
33
|
+
// Self-gate: rust має бути enabled
|
|
34
|
+
if (!config.rules.includes('rust') || config.disableRules.includes('rust')) {
|
|
35
|
+
return reporter.getExitCode()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const manifests = await resolveAllCargoManifests(cwd)
|
|
39
|
+
if (manifests.length === 0) {
|
|
40
|
+
// rust enabled, але Cargo.toml ще немає — silently skip (manifest може з'явитися пізніше)
|
|
41
|
+
return reporter.getExitCode()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(BASELINE_PATH)) {
|
|
45
|
+
reporter.fail(
|
|
46
|
+
`.cargo/mutants.toml canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
|
|
47
|
+
)
|
|
48
|
+
return reporter.getExitCode()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const manifestPath of manifests) {
|
|
52
|
+
const cargoDir = dirname(manifestPath)
|
|
53
|
+
const target = join(cargoDir, '.cargo', 'mutants.toml')
|
|
54
|
+
|
|
55
|
+
if (existsSync(target)) {
|
|
56
|
+
reporter.pass(`.cargo/mutants.toml існує (${relative(cwd, target)})`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await mkdir(dirname(target), { recursive: true })
|
|
61
|
+
await copyFile(BASELINE_PATH, target)
|
|
62
|
+
reporter.pass(`.cargo/mutants.toml створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
|
|
63
|
+
}
|
|
64
|
+
return reporter.getExitCode()
|
|
65
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** @type {import('@stryker-mutator/core').PartialStrykerOptions} */
|
|
2
|
+
export default {
|
|
3
|
+
testRunner: 'command',
|
|
4
|
+
commandRunner: { command: 'bun test' },
|
|
5
|
+
// inPlace: уникає hoisted-node_modules issues у Bun monorepo (sandbox-копія втрачає resolution).
|
|
6
|
+
// Також тести, що читають git/fs-state (integration checks), працюють тільки in-place.
|
|
7
|
+
inPlace: true,
|
|
8
|
+
tempDirName: 'reports/stryker/.tmp',
|
|
9
|
+
reporters: ['json', 'clear-text'],
|
|
10
|
+
jsonReporter: { fileName: 'reports/stryker/mutation.json' },
|
|
11
|
+
coverageAnalysis: 'off'
|
|
12
|
+
}
|
|
@@ -18,7 +18,7 @@ const TESTS_DIR_NAME = 'tests'
|
|
|
18
18
|
/**
|
|
19
19
|
* Чи файл є JS-тестом (`*.test.mjs`).
|
|
20
20
|
* @param {string} absPath абсолютний шлях
|
|
21
|
-
* @returns {boolean}
|
|
21
|
+
* @returns {boolean} true для шляхів із суфіксом `.test.mjs`
|
|
22
22
|
*/
|
|
23
23
|
function isTestFile(absPath) {
|
|
24
24
|
return basename(absPath).endsWith('.test.mjs')
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Концерн `stryker_config` правила test (test.mdc): якщо `js-lint` присутнє в
|
|
3
|
+
* `.n-cursor.json#rules` і не у `disable-rules` — резолвить ВСІ JS-roots
|
|
4
|
+
* (всі workspaces з package.json, або cwd у single-package) і копіює canonical
|
|
5
|
+
* baseline `stryker.config.mjs` у кожен root, де файлу немає.
|
|
6
|
+
*
|
|
7
|
+
* Self-gating: концерн silently skips коли `js-lint` не enabled — це навмисно,
|
|
8
|
+
* щоб не шуміти у single-language проєктах без JS coverage tooling.
|
|
9
|
+
*
|
|
10
|
+
* Baseline — мінімум для запуску Stryker з bun test runner; mutate-патерни
|
|
11
|
+
* лишаються на Stryker defaults (`src/**\/*.{js,mjs,ts,jsx,tsx,cjs}`).
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { copyFile } from 'node:fs/promises'
|
|
15
|
+
import { dirname, join, relative } from 'node:path'
|
|
16
|
+
import { fileURLToPath } from 'node:url'
|
|
17
|
+
|
|
18
|
+
import { createCheckReporter } from '../../../scripts/lib/check-reporter.mjs'
|
|
19
|
+
import { readNCursorConfigLite } from '../../../scripts/lib/read-n-cursor-config-lite.mjs'
|
|
20
|
+
import { resolveAllJsRoots } from '../../../scripts/utils/resolve-js-root.mjs'
|
|
21
|
+
|
|
22
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const BASELINE_PATH = join(HERE, 'data', 'stryker_config', 'stryker.config.baseline.mjs')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {Promise<number>} 0 — OK або silently skipped, 1 — порушення
|
|
27
|
+
*/
|
|
28
|
+
export async function check() {
|
|
29
|
+
const reporter = createCheckReporter()
|
|
30
|
+
const cwd = process.cwd()
|
|
31
|
+
const config = await readNCursorConfigLite(cwd)
|
|
32
|
+
|
|
33
|
+
// Self-gate: js-lint має бути enabled
|
|
34
|
+
if (!config.rules.includes('js-lint') || config.disableRules.includes('js-lint')) {
|
|
35
|
+
return reporter.getExitCode()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const jsRoots = await resolveAllJsRoots(cwd)
|
|
39
|
+
if (jsRoots.length === 0) {
|
|
40
|
+
reporter.fail('test: js-lint enabled, але кореневий package.json не знайдено (test.mdc)')
|
|
41
|
+
return reporter.getExitCode()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!existsSync(BASELINE_PATH)) {
|
|
45
|
+
reporter.fail(
|
|
46
|
+
`stryker.config.mjs canonical baseline не знайдено (${BASELINE_PATH}) — перевстанови @nitra/cursor`
|
|
47
|
+
)
|
|
48
|
+
return reporter.getExitCode()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const jsRoot of jsRoots) {
|
|
52
|
+
const target = join(jsRoot, 'stryker.config.mjs')
|
|
53
|
+
if (existsSync(target)) {
|
|
54
|
+
reporter.pass(`stryker.config.mjs існує (${relative(cwd, target)})`)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
await copyFile(BASELINE_PATH, target)
|
|
58
|
+
reporter.pass(`stryker.config.mjs створено з canonical baseline (${relative(cwd, target)}) (test.mdc)`)
|
|
59
|
+
}
|
|
60
|
+
return reporter.getExitCode()
|
|
61
|
+
}
|
package/rules/test/test.mdc
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: JS-тести (*.test.mjs) живуть у
|
|
3
|
-
version: '
|
|
4
|
-
|
|
2
|
+
description: JS-тести (*.test.mjs) живуть у tests/. Правило `test` керує stryker.config.mjs (якщо js-lint enabled) і .cargo/mutants.toml (якщо rust enabled).
|
|
3
|
+
version: '2.0'
|
|
4
|
+
globs: "**/{.n-cursor.json,package.json,Cargo.toml,stryker.config.mjs,.cargo/mutants.toml},**/*.test.mjs"
|
|
5
|
+
alwaysApply: false
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
## Конвенція розміщення тестів
|
|
@@ -68,3 +69,15 @@ Recursive globs ловлять файли всередині `tests/` так с
|
|
|
68
69
|
У `package.json` (корінь) має бути `scripts.coverage` із викликом `n-cursor coverage`:
|
|
69
70
|
|
|
70
71
|
Канон `scripts.coverage` (substring requirement): [package.json.contains.json](./policy/package_json/template/package.json.contains.json)
|
|
72
|
+
|
|
73
|
+
## Налаштування mutation-testing
|
|
74
|
+
|
|
75
|
+
Якщо у `.n-cursor.json#rules` присутнє правило `js-lint` — правило `test` створює canonical baseline `stryker.config.mjs` у **кожному** JS-root проєкту: у кожному workspace з власним `package.json` (або в корені для single-package). У monorepo з `workspaces: ['app', 'scripts']` отримаєте `app/stryker.config.mjs` і `scripts/stryker.config.mjs`.
|
|
76
|
+
|
|
77
|
+
Канон Stryker config (мінімум для роботи з `bun test`): [stryker.config.baseline.mjs](./js/data/stryker_config/stryker.config.baseline.mjs)
|
|
78
|
+
|
|
79
|
+
Аналогічно, якщо `rust` присутнє в `rules` — створюється `.cargo/mutants.toml` у каталозі **кожного** Cargo.toml-маніфесту: кореневий `Cargo.toml`, `<workspace>/src-tauri/Cargo.toml` (Tauri-патерн) і `<workspace>/Cargo.toml` (flat workspace).
|
|
80
|
+
|
|
81
|
+
Канон cargo-mutants config: [mutants.toml.baseline](./js/data/cargo_mutants_config/mutants.toml.baseline)
|
|
82
|
+
|
|
83
|
+
Customization (mutate patterns, exclude rules, timeout) — відповідальність проєкту-споживача; концерни лише забезпечують наявність файлу як стартового baseline в кожному з виявлених workspace-каталогів.
|
package/rules/text/fix.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
|
|
1
2
|
import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -10,10 +11,9 @@ export function run(ctx) {
|
|
|
10
11
|
return runStandardRule(import.meta.dirname, ctx)
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
if (
|
|
14
|
+
if (isRunAsCli()) {
|
|
14
15
|
// Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
|
|
15
16
|
// (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
|
|
16
|
-
|
|
17
|
-
// eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
17
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
18
18
|
process.exit(await runRuleCli(import.meta.dirname))
|
|
19
19
|
}
|
package/rules/vue/fix.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRunAsCli, runRuleCli } from '../../scripts/lib/run-rule-cli.mjs'
|
|
1
2
|
import { runStandardRule } from '../../scripts/lib/run-standard-rule.mjs'
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -10,10 +11,9 @@ export function run(ctx) {
|
|
|
10
11
|
return runStandardRule(import.meta.dirname, ctx)
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
if (
|
|
14
|
+
if (isRunAsCli()) {
|
|
14
15
|
// Standalone: bun rules/<id>/fix.mjs — повний еквівалент `npx @nitra/cursor fix <id>`
|
|
15
16
|
// (config-loading + whitelist + summary). Дві ролі fix.mjs: library (run) + standalone (main).
|
|
16
|
-
|
|
17
|
-
// eslint-disable-next-line unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
17
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- standalone entry-point має повертати exit-code для CI/IDE
|
|
18
18
|
process.exit(await runRuleCli(import.meta.dirname))
|
|
19
19
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* Library-mode виклик з CLI orchestration — інше: див. `runStandardRule` + `fix.mjs::run(ctx)`.
|
|
8
8
|
*/
|
|
9
9
|
import { basename } from 'node:path'
|
|
10
|
+
import { pathToFileURL } from 'node:url'
|
|
10
11
|
|
|
11
12
|
import { isRuleEnabled, readNCursorConfigLite } from './read-n-cursor-config-lite.mjs'
|
|
12
13
|
import { runStandardRule } from './run-standard-rule.mjs'
|
|
@@ -14,6 +15,16 @@ import { getOrCreateWalkCache } from '../utils/walk-cache.mjs'
|
|
|
14
15
|
|
|
15
16
|
const PACKAGE_NAME = '@nitra/cursor'
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Чи поточний модуль запущено як CLI entry-point (`bun rules/<id>/fix.mjs`).
|
|
20
|
+
* @returns {boolean} true, коли `import.meta.url` збігається з `process.argv[1]`
|
|
21
|
+
*/
|
|
22
|
+
export function isRunAsCli() {
|
|
23
|
+
const entry = process.argv[1]
|
|
24
|
+
if (!entry) return false
|
|
25
|
+
return import.meta.url === pathToFileURL(entry).href
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
/**
|
|
18
29
|
* @param {string} ruleDir абсолютний шлях до `rules/<id>/`
|
|
19
30
|
* @returns {Promise<number>} 0 — OK або правило не enabled; 1 — порушення
|
|
@@ -32,7 +32,7 @@ import { withLock } from '../utils/with-lock.mjs'
|
|
|
32
32
|
* @param {RuleContext} [ctx] контекст прогону (walkCache тощо)
|
|
33
33
|
* @returns {Promise<number>} 0 OK, 1 violations
|
|
34
34
|
*/
|
|
35
|
-
export
|
|
35
|
+
export function runStandardRule(ruleDir, ctx = {}) {
|
|
36
36
|
const ruleId = basename(ruleDir)
|
|
37
37
|
const bundledRulesDir = dirname(ruleDir)
|
|
38
38
|
return withLock(`fix-${ruleId}`, async () => {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Резолвить шлях до Cargo.toml у проєкті: cwd/Cargo.toml або в одному з
|
|
3
|
+
* workspace-підкаталогів (з підтримкою Tauri-патерну `<workspace>/src-tauri/`).
|
|
4
|
+
* Спільна утиліта для coverage-провайдера rust і test-концерну cargo_mutants_config.
|
|
5
|
+
* Повертає null (а не throw) щоб callsite-и могли gracefully skip-нути.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { readFile } from 'node:fs/promises'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} cwd корінь проєкту
|
|
13
|
+
* @returns {Promise<string|null>} абсолютний шлях до Cargo.toml або null
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveCargoManifest(cwd) {
|
|
16
|
+
const rootManifest = join(cwd, 'Cargo.toml')
|
|
17
|
+
if (existsSync(rootManifest)) return rootManifest
|
|
18
|
+
|
|
19
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
20
|
+
if (existsSync(rootPkgPath)) {
|
|
21
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
22
|
+
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
23
|
+
for (const ws of workspaces) {
|
|
24
|
+
const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
|
|
25
|
+
if (existsSync(tauri)) return tauri
|
|
26
|
+
const flat = join(cwd, ws, 'Cargo.toml')
|
|
27
|
+
if (existsSync(flat)) return flat
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Plural-варіант: повертає всі Cargo.toml-маніфести в проєкті — корінь
|
|
35
|
+
* (`cwd/Cargo.toml`) і у workspace-підкаталогах (`<ws>/src-tauri/Cargo.toml`
|
|
36
|
+
* пріоритетніше за `<ws>/Cargo.toml`). Порожній масив якщо нічого не знайдено.
|
|
37
|
+
* Використовується test-концерном `cargo_mutants_config` для per-manifest
|
|
38
|
+
* baseline-копіювання.
|
|
39
|
+
* @param {string} cwd корінь проєкту
|
|
40
|
+
* @returns {Promise<string[]>} абсолютні шляхи до знайдених Cargo.toml
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveAllCargoManifests(cwd) {
|
|
43
|
+
const manifests = []
|
|
44
|
+
const rootManifest = join(cwd, 'Cargo.toml')
|
|
45
|
+
if (existsSync(rootManifest)) manifests.push(rootManifest)
|
|
46
|
+
|
|
47
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
48
|
+
if (existsSync(rootPkgPath)) {
|
|
49
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
50
|
+
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
51
|
+
for (const ws of workspaces) {
|
|
52
|
+
const tauri = join(cwd, ws, 'src-tauri', 'Cargo.toml')
|
|
53
|
+
if (existsSync(tauri)) {
|
|
54
|
+
manifests.push(tauri)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const flat = join(cwd, ws, 'Cargo.toml')
|
|
58
|
+
if (existsSync(flat)) manifests.push(flat)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return manifests
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Резолвить корінь JS-коду в проєкті: для workspace-projects — перший workspace
|
|
3
|
+
* (наприклад `app/` у mlmail), для single-package — корінь cwd. Спільна утиліта
|
|
4
|
+
* для coverage-провайдера js-lint і test-концерну stryker_config (DRY).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} cwd корінь проєкту (де `.n-cursor.json` і кореневий package.json)
|
|
12
|
+
* @returns {Promise<string|null>} абсолютний шлях до JS-root або null без кореневого package.json
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveJsRoot(cwd) {
|
|
15
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
16
|
+
if (!existsSync(rootPkgPath)) return null
|
|
17
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
18
|
+
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
19
|
+
if (workspaces.length > 0) {
|
|
20
|
+
const wsPath = join(cwd, workspaces[0])
|
|
21
|
+
if (existsSync(join(wsPath, 'package.json'))) return wsPath
|
|
22
|
+
}
|
|
23
|
+
return cwd
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Plural-варіант: повертає всі JS-roots проєкту. Для workspace-projects — кожен
|
|
28
|
+
* workspace з власним `package.json`; для single-package — `[cwd]`. Порожній
|
|
29
|
+
* масив без кореневого package.json. Використовується test-концерном
|
|
30
|
+
* `stryker_config` для per-workspace baseline-копіювання.
|
|
31
|
+
* @param {string} cwd корінь проєкту
|
|
32
|
+
* @returns {Promise<string[]>} абсолютні шляхи до всіх JS-roots
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveAllJsRoots(cwd) {
|
|
35
|
+
const rootPkgPath = join(cwd, 'package.json')
|
|
36
|
+
if (!existsSync(rootPkgPath)) return []
|
|
37
|
+
const rootPkg = JSON.parse(await readFile(rootPkgPath, 'utf8'))
|
|
38
|
+
const workspaces = Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []
|
|
39
|
+
if (workspaces.length === 0) return [cwd]
|
|
40
|
+
const roots = []
|
|
41
|
+
for (const ws of workspaces) {
|
|
42
|
+
const wsPath = join(cwd, ws)
|
|
43
|
+
if (existsSync(join(wsPath, 'package.json'))) roots.push(wsPath)
|
|
44
|
+
}
|
|
45
|
+
return roots.length > 0 ? roots : [cwd]
|
|
46
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Алгоритм: mkdirSync-based lock, перевірка живості PID, sha256-dedup з TTL.
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs'
|
|
6
|
-
import
|
|
6
|
+
import { join } from 'node:path'
|
|
7
7
|
import * as os from 'node:os'
|
|
8
8
|
import { setTimeout as sleep } from 'node:timers/promises'
|
|
9
9
|
import { worktreeFingerprint } from './worktree-fingerprint.mjs'
|
|
@@ -12,9 +12,14 @@ const DEFAULTS = {
|
|
|
12
12
|
ttl: 600_000,
|
|
13
13
|
staleThreshold: 1_800_000,
|
|
14
14
|
waitTimeout: 1_200_000,
|
|
15
|
-
pollInterval:
|
|
15
|
+
pollInterval: 1500
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Чи процес із заданим PID ще живий на поточному host.
|
|
20
|
+
* @param {number} pid ідентифікатор процесу з owner.json
|
|
21
|
+
* @returns {boolean} true, якщо process.kill(pid, 0) не кинув помилку
|
|
22
|
+
*/
|
|
18
23
|
function isAlive(pid) {
|
|
19
24
|
try {
|
|
20
25
|
process.kill(pid, 0)
|
|
@@ -24,14 +29,21 @@ function isAlive(pid) {
|
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Повертає функцію, що знімає lock-директорію.
|
|
34
|
+
* @param {string} lockDir абсолютний шлях до lock-директорії
|
|
35
|
+
* @returns {() => void} release-колбек для finally/signal handler
|
|
36
|
+
*/
|
|
27
37
|
function makeRelease(lockDir) {
|
|
28
38
|
return () => fs.rmSync(lockDir, { recursive: true, force: true })
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/**
|
|
32
|
-
*
|
|
33
|
-
* @param {string|null}
|
|
34
|
-
* @param {
|
|
42
|
+
* Чи можна пропустити повторний прогін за кешованим result.json.
|
|
43
|
+
* @param {{exitCode:number, fingerprint:string|null, finishedAt:number}} result попередній результат з result.json
|
|
44
|
+
* @param {string|null} fingerprint поточний fingerprint робочого дерева
|
|
45
|
+
* @param {number} ttl TTL дедуплікації в мілісекундах
|
|
46
|
+
* @returns {boolean} true, якщо попередній успішний прогін можна повторно використати
|
|
35
47
|
*/
|
|
36
48
|
export function shouldDedup(result, fingerprint, ttl) {
|
|
37
49
|
if (result.exitCode !== 0) return false
|
|
@@ -41,27 +53,26 @@ export function shouldDedup(result, fingerprint, ttl) {
|
|
|
41
53
|
}
|
|
42
54
|
|
|
43
55
|
/**
|
|
44
|
-
*
|
|
45
|
-
* @param {
|
|
46
|
-
* @param {
|
|
47
|
-
* @
|
|
56
|
+
* Серіалізує важку команду через атомарний lock і dedup за fingerprint.
|
|
57
|
+
* @param {string} key ключ локу (наприклад `lint-ga`, `fix-bun`)
|
|
58
|
+
* @param {() => number | Promise<number>} runFn основна робота; повертає exit code
|
|
59
|
+
* @param {{ttl?:number, staleThreshold?:number, waitTimeout?:number, pollInterval?:number, cacheDir?:string, getFingerprint?:() => string | null}} [opts] TTL, шлях кешу та override fingerprint
|
|
60
|
+
* @returns {Promise<number>} exit code виконаної або дедуплікованої команди
|
|
48
61
|
*/
|
|
49
62
|
export async function withLock(key, runFn, opts = {}) {
|
|
50
63
|
const { ttl, staleThreshold, waitTimeout, pollInterval } = { ...DEFAULTS, ...opts }
|
|
51
64
|
const getFingerprint = opts.getFingerprint ?? worktreeFingerprint
|
|
52
|
-
const cacheDir = opts.cacheDir ??
|
|
53
|
-
const lockDir =
|
|
54
|
-
const ownerFile =
|
|
55
|
-
const resultFile =
|
|
65
|
+
const cacheDir = opts.cacheDir ?? join(process.cwd(), 'node_modules/.cache/n-cursor', key)
|
|
66
|
+
const lockDir = join(cacheDir, 'lock')
|
|
67
|
+
const ownerFile = join(lockDir, 'owner.json')
|
|
68
|
+
const resultFile = join(cacheDir, 'result.json')
|
|
56
69
|
const release = makeRelease(lockDir)
|
|
57
70
|
|
|
58
71
|
const fingerprint = getFingerprint()
|
|
59
72
|
fs.mkdirSync(cacheDir, { recursive: true })
|
|
60
73
|
|
|
61
74
|
const loopStart = Date.now()
|
|
62
|
-
let locked = false
|
|
63
75
|
|
|
64
|
-
// eslint-disable-next-line no-constant-condition
|
|
65
76
|
while (true) {
|
|
66
77
|
if (Date.now() - loopStart >= waitTimeout) {
|
|
67
78
|
console.error(`⚠️ ${key}: чекав ${waitTimeout / 60_000} хв — запускаю без локу`)
|
|
@@ -73,7 +84,6 @@ export async function withLock(key, runFn, opts = {}) {
|
|
|
73
84
|
ownerFile,
|
|
74
85
|
JSON.stringify({ pid: process.pid, host: os.hostname(), startedAt: Date.now(), fingerprint })
|
|
75
86
|
)
|
|
76
|
-
locked = true
|
|
77
87
|
break
|
|
78
88
|
} catch (error) {
|
|
79
89
|
if (error.code !== 'EEXIST') throw error
|
|
@@ -113,6 +123,7 @@ export async function withLock(key, runFn, opts = {}) {
|
|
|
113
123
|
|
|
114
124
|
const onSignal = () => {
|
|
115
125
|
release()
|
|
126
|
+
// eslint-disable-next-line n/no-process-exit, unicorn/no-process-exit -- SIGINT/SIGTERM мають завершити процес із кодом 130
|
|
116
127
|
process.exit(130)
|
|
117
128
|
}
|
|
118
129
|
process.once('SIGINT', onSignal)
|
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fingerprint поточного стану git-робочого дерева.
|
|
3
|
-
* Повертає sha256-hex (64 символи) або null, якщо не в git-репо.
|
|
4
|
-
* @param {typeof import('child_process').spawnSync} spawn
|
|
5
|
-
*/
|
|
6
1
|
import { spawnSync } from 'node:child_process'
|
|
7
2
|
import { createHash } from 'node:crypto'
|
|
8
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Fingerprint поточного стану git-робочого дерева.
|
|
6
|
+
* @param {typeof import('child_process').spawnSync} [spawn] sync-виклик git (ін'єкція для тестів)
|
|
7
|
+
* @returns {string|null} sha256-hex (64 символи) або null, якщо не в git-репо
|
|
8
|
+
*/
|
|
9
9
|
export function worktreeFingerprint(spawn = spawnSync) {
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* @param {string[]} args аргументи підкоманди git
|
|
12
|
+
* @returns {string} stdout git-команди
|
|
13
|
+
*/
|
|
11
14
|
function git(args) {
|
|
12
15
|
const r = spawn('git', args, { encoding: 'utf8' })
|
|
13
16
|
if (r.status !== 0 || r.error) throw new Error(`git ${args[0]} failed`)
|
|
@@ -21,7 +24,7 @@ export function worktreeFingerprint(spawn = spawnSync) {
|
|
|
21
24
|
// повертаються у `"..."` формі, і `git hash-object` не знаходить файл → throw → fingerprint=null.
|
|
22
25
|
const untrackedRaw = git(['ls-files', '-z', '--others', '--exclude-standard'])
|
|
23
26
|
const untrackedFiles = untrackedRaw.split('\0').filter(Boolean)
|
|
24
|
-
const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).
|
|
27
|
+
const pairs = untrackedFiles.map(f => `${f}:${git(['hash-object', f]).trim()}`).toSorted()
|
|
25
28
|
const raw = [commitHash, diffText, ...pairs].join('\n')
|
|
26
29
|
return createHash('sha256').update(raw).digest('hex')
|
|
27
30
|
} catch {
|