@njeybe/nativ 2.8.0 → 2.9.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.
- package/CHANGELOG.md +21 -0
- package/README.md +26 -1
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +36 -0
- package/dist/commands/doctor.js +7 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +1 -1
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +1 -0
- package/dist/commands/task/task-add.js +49 -6
- package/dist/commands/task/task-common.d.ts +6 -0
- package/dist/commands/task/task-lifecycle.d.ts +3 -1
- package/dist/commands/task/task-lifecycle.js +40 -2
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +1 -1
- package/dist/commands/verify.d.ts +7 -0
- package/dist/commands/verify.js +26 -2
- package/dist/core/checks/docs-check.d.ts +18 -0
- package/dist/core/checks/docs-check.js +186 -0
- package/dist/core/code-shape.d.ts +7 -2
- package/dist/core/code-shape.js +46 -16
- package/dist/core/env-reads.d.ts +15 -0
- package/dist/core/env-reads.js +126 -0
- package/dist/core/human-gate.js +6 -3
- package/dist/core/profiles.d.ts +8 -0
- package/dist/core/profiles.js +43 -2
- package/dist/core/root-resolver.d.ts +4 -0
- package/dist/core/root-resolver.js +32 -0
- package/dist/core/setup/gitignore-block.d.ts +17 -0
- package/dist/core/setup/gitignore-block.js +182 -0
- package/dist/core/setup/setup-planner.js +36 -3
- package/dist/core/setup/types.d.ts +6 -0
- package/dist/core/verifier/clean-tree.d.ts +14 -0
- package/dist/core/verifier/clean-tree.js +151 -0
- package/dist/core/verifier/executor.d.ts +3 -1
- package/dist/core/verifier/executor.js +54 -31
- package/dist/core/verifier/isolation.d.ts +25 -0
- package/dist/core/verifier/isolation.js +77 -0
- package/dist/core/verifier/portability-lint.d.ts +2 -0
- package/dist/core/verifier/portability-lint.js +64 -0
- package/dist/core/verifier/skip-policy.d.ts +8 -0
- package/dist/core/verifier/skip-policy.js +13 -0
- package/dist/core/verifier/task-verifier.d.ts +6 -8
- package/dist/core/verifier/task-verifier.js +68 -39
- package/dist/core/verifier/types.d.ts +4 -0
- package/dist/core/verifier/verify-env.d.ts +2 -0
- package/dist/core/verifier/verify-env.js +19 -0
- package/dist/index.js +33 -4
- package/dist/mcp/server.js +9 -2
- package/dist/scanner/types.d.ts +4 -0
- package/dist/scanner/types.js +9 -0
- package/dist/server/routes/task-routes.d.ts +1 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +14 -7
- package/plugin/agents/verifier.md +16 -14
- package/plugin/skills/nativ/SKILL.md +11 -8
- package/templates/AGENTS.md +11 -8
- package/templates/claude-agents/architect.md +14 -7
- package/templates/claude-agents/verifier.md +16 -14
- package/templates/dot-ai/subagents/qa-tester.md +14 -13
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { buildDesiredConfig, isObject } from './config-merger.js';
|
|
5
|
+
import { AGENT_FILES, MCP_SERVER_NAME, managedState } from './types.js';
|
|
6
|
+
export const BLOCK_START = '# >>> nativ (managed) >>>';
|
|
7
|
+
export const BLOCK_END = '# <<< nativ (managed) <<<';
|
|
8
|
+
/** Secret and machine-local paths: ignored even when the team shares nativ's files. */
|
|
9
|
+
export const SHARED_PATHS = [
|
|
10
|
+
'/.nativ/*.local.json',
|
|
11
|
+
'/.nativ/logs/',
|
|
12
|
+
'/.nativ/*.log',
|
|
13
|
+
'/.nativ/notify-state.json',
|
|
14
|
+
'/.worktrees/',
|
|
15
|
+
];
|
|
16
|
+
const read = (root, rel) => {
|
|
17
|
+
try {
|
|
18
|
+
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/** Exactly what older nativ versions wrote for CLAUDE.md / GEMINI.md. */
|
|
25
|
+
function isOldPointer(text) {
|
|
26
|
+
const lf = text.replace(/\r\n/g, '\n');
|
|
27
|
+
return ['Claude Code', 'Gemini / Antigravity'].some((t) => lf === `# ${t} directive\n\n@AGENTS.md\n`);
|
|
28
|
+
}
|
|
29
|
+
function isNativOwnedFile(text, allowPointer) {
|
|
30
|
+
if (text === null)
|
|
31
|
+
return false;
|
|
32
|
+
if (managedState(text) !== 'unmanaged')
|
|
33
|
+
return true;
|
|
34
|
+
return allowPointer && isOldPointer(text);
|
|
35
|
+
}
|
|
36
|
+
const parseObject = (text) => {
|
|
37
|
+
if (text === null)
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(text);
|
|
41
|
+
return isObject(value) ? value : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
48
|
+
/** nativ's own server entry, for whatever command setup was configured to use. */
|
|
49
|
+
function ownedServer(entry) {
|
|
50
|
+
if (!isObject(entry) || typeof entry.command !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
if (Object.keys(entry).some((k) => k !== 'command' && k !== 'args'))
|
|
53
|
+
return false;
|
|
54
|
+
const args = entry.args;
|
|
55
|
+
if (!Array.isArray(args) || args.length === 0 || !args.every((a) => typeof a === 'string'))
|
|
56
|
+
return false;
|
|
57
|
+
const invocation = { command: entry.command, prefixArgs: args.slice(0, -1), portable: true };
|
|
58
|
+
const want = buildDesiredConfig(invocation).mcpServer;
|
|
59
|
+
return sameJson(entry, { command: want.command, args: want.args });
|
|
60
|
+
}
|
|
61
|
+
function onlyNativMcp(root) {
|
|
62
|
+
const json = parseObject(read(root, '.mcp.json'));
|
|
63
|
+
if (!json)
|
|
64
|
+
return false;
|
|
65
|
+
const keys = Object.keys(json).filter((k) => k !== 'mcpServers');
|
|
66
|
+
if (keys.length || !isObject(json.mcpServers))
|
|
67
|
+
return false;
|
|
68
|
+
const servers = json.mcpServers;
|
|
69
|
+
const names = Object.keys(servers);
|
|
70
|
+
return names.length > 0 && names.every((n) => n === MCP_SERVER_NAME && ownedServer(servers[n]));
|
|
71
|
+
}
|
|
72
|
+
/** CLI strings that setup wrote into hook commands; `nativ` is always a candidate. */
|
|
73
|
+
function clisFromHooks(hooks) {
|
|
74
|
+
const found = new Set(['nativ']);
|
|
75
|
+
if (!isObject(hooks))
|
|
76
|
+
return [...found];
|
|
77
|
+
for (const groups of Object.values(hooks)) {
|
|
78
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
79
|
+
for (const h of isObject(g) && Array.isArray(g.hooks) ? g.hooks : []) {
|
|
80
|
+
const m = isObject(h) ? /^(.+) hook (check|context)$/.exec(String(h.command)) : null;
|
|
81
|
+
if (m)
|
|
82
|
+
found.add(m[1]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...found];
|
|
87
|
+
}
|
|
88
|
+
/** The generated config for an already-rendered CLI string (it may hold spaces or quotes). */
|
|
89
|
+
function desiredFor(cli) {
|
|
90
|
+
const mark = 'CLIMARK';
|
|
91
|
+
const text = JSON.stringify(buildDesiredConfig({ command: mark, prefixArgs: [], portable: true }));
|
|
92
|
+
return JSON.parse(text.split(mark).join(JSON.stringify(cli).slice(1, -1)));
|
|
93
|
+
}
|
|
94
|
+
function ownedHooks(hooks, wants) {
|
|
95
|
+
if (!isObject(hooks))
|
|
96
|
+
return false;
|
|
97
|
+
const groupOk = (event, g) => wants.some((want) => {
|
|
98
|
+
const w = event === 'PreToolUse' ? want.preToolUse : event === 'SessionStart' ? want.sessionStart : null;
|
|
99
|
+
const hook = w && { type: 'command', command: w.command, timeout: w.timeout };
|
|
100
|
+
return w !== null && sameJson(g, { matcher: w.matcher, hooks: [hook] });
|
|
101
|
+
});
|
|
102
|
+
return Object.entries(hooks).every(([event, groups]) => Array.isArray(groups) && groups.every((g) => groupOk(event, g)));
|
|
103
|
+
}
|
|
104
|
+
// Setup unions entries, so a file may hold those of an older invocation too.
|
|
105
|
+
function ownedPermissions(perms, wants) {
|
|
106
|
+
if (!isObject(perms))
|
|
107
|
+
return false;
|
|
108
|
+
const keys = ['allow', 'deny', 'ask'];
|
|
109
|
+
return Object.entries(perms).every(([k, v]) => {
|
|
110
|
+
const key = keys.find((x) => x === k);
|
|
111
|
+
return key !== undefined && Array.isArray(v) &&
|
|
112
|
+
v.every((e) => typeof e === 'string' && wants.some((w) => w[key].includes(e)));
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function onlyNativSettings(root) {
|
|
116
|
+
const json = parseObject(read(root, '.claude/settings.json'));
|
|
117
|
+
if (!json)
|
|
118
|
+
return false;
|
|
119
|
+
const allowed = new Set(['permissions', 'hooks', 'enabledMcpjsonServers']);
|
|
120
|
+
if (Object.keys(json).some((k) => !allowed.has(k)))
|
|
121
|
+
return false;
|
|
122
|
+
const wants = clisFromHooks(json.hooks).map(desiredFor);
|
|
123
|
+
if (json.permissions !== undefined && !ownedPermissions(json.permissions, wants))
|
|
124
|
+
return false;
|
|
125
|
+
if (json.hooks !== undefined && !ownedHooks(json.hooks, wants))
|
|
126
|
+
return false;
|
|
127
|
+
const enabled = json.enabledMcpjsonServers;
|
|
128
|
+
return enabled === undefined || (Array.isArray(enabled) && enabled.every((s) => s === MCP_SERVER_NAME));
|
|
129
|
+
}
|
|
130
|
+
/** Root-anchored paths nativ generated in this project, per the git-visibility choice. */
|
|
131
|
+
export function listGeneratedPaths(root, visibility = 'hidden') {
|
|
132
|
+
if (visibility === 'shared')
|
|
133
|
+
return [...SHARED_PATHS];
|
|
134
|
+
const paths = ['/.ai/', '/.nativ/', '/.worktrees/'];
|
|
135
|
+
for (const file of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
|
|
136
|
+
if (isNativOwnedFile(read(root, file), true))
|
|
137
|
+
paths.push(`/${file}`);
|
|
138
|
+
}
|
|
139
|
+
for (const file of AGENT_FILES) {
|
|
140
|
+
const rel = `.claude/agents/${file}`;
|
|
141
|
+
if (isNativOwnedFile(read(root, rel), false))
|
|
142
|
+
paths.push(`/${rel}`);
|
|
143
|
+
}
|
|
144
|
+
if (onlyNativMcp(root))
|
|
145
|
+
paths.push('/.mcp.json');
|
|
146
|
+
if (onlyNativSettings(root))
|
|
147
|
+
paths.push('/.claude/settings.json');
|
|
148
|
+
return paths;
|
|
149
|
+
}
|
|
150
|
+
export function renderBlock(paths, eol = '\n') {
|
|
151
|
+
return [BLOCK_START, ...paths, BLOCK_END].join(eol);
|
|
152
|
+
}
|
|
153
|
+
/** Inserts or replaces the managed block; text outside it and its line endings stay as they were. */
|
|
154
|
+
export function applyBlock(existing, paths) {
|
|
155
|
+
const text = existing ?? '';
|
|
156
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
157
|
+
const block = renderBlock(paths, eol);
|
|
158
|
+
const start = text.indexOf(BLOCK_START);
|
|
159
|
+
const endAt = start === -1 ? -1 : text.indexOf(BLOCK_END, start);
|
|
160
|
+
if (start !== -1 && endAt !== -1) {
|
|
161
|
+
return text.slice(0, start) + block + text.slice(endAt + BLOCK_END.length);
|
|
162
|
+
}
|
|
163
|
+
if (text.trim() === '')
|
|
164
|
+
return block + eol;
|
|
165
|
+
const sep = text.endsWith('\n') ? eol : eol + eol;
|
|
166
|
+
return text + sep + block + eol;
|
|
167
|
+
}
|
|
168
|
+
/** Lists generated paths that git already tracks. Non-git folders give an empty list. */
|
|
169
|
+
export function trackedNativPaths(root, paths) {
|
|
170
|
+
const none = { tracked: [], command: '' };
|
|
171
|
+
const specs = paths.map((p) => p.replace(/^\//, '')).filter((p) => !p.includes('*'));
|
|
172
|
+
if (!specs.length)
|
|
173
|
+
return none;
|
|
174
|
+
const out = spawnSync('git', ['ls-files', '-z', '--', ...specs], { cwd: root, encoding: 'utf8' });
|
|
175
|
+
if (out.status !== 0 || !out.stdout)
|
|
176
|
+
return none;
|
|
177
|
+
const files = out.stdout.split('\0').filter(Boolean);
|
|
178
|
+
const tracked = specs.filter((s) => files.some((f) => f === s || f.startsWith(s.endsWith('/') ? s : `${s}/`)));
|
|
179
|
+
if (!tracked.length)
|
|
180
|
+
return none;
|
|
181
|
+
return { tracked, command: `git rm --cached -r -- ${tracked.join(' ')}` };
|
|
182
|
+
}
|
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { MCP_SERVER_NAME, AGENT_FILES, resolveCliInvocation, isMachineBoundPath, stampManaged, managedState, } from './types.js';
|
|
5
5
|
import { buildDesiredConfig, mergeClaudeSettings, mergeMcpConfig, isObject, } from './config-merger.js';
|
|
6
|
+
import { applyBlock, listGeneratedPaths, trackedNativPaths } from './gitignore-block.js';
|
|
6
7
|
import { loadEnforcementMode, parseJsonLoose } from '../enforcement.js';
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
export function getTemplatesDir() {
|
|
@@ -44,6 +45,18 @@ export function configuredInvocation(root) {
|
|
|
44
45
|
return null;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
48
|
+
/** Disk-derived paths plus files this run creates, in a fixed order so reruns match. */
|
|
49
|
+
function blockPaths(root, visibility, changes) {
|
|
50
|
+
const disk = listGeneratedPaths(root, visibility);
|
|
51
|
+
if (visibility === 'shared')
|
|
52
|
+
return disk;
|
|
53
|
+
const created = new Set(changes.filter((c) => c.action === 'created').map((c) => `/${c.path}`));
|
|
54
|
+
const canonical = [
|
|
55
|
+
'/.ai/', '/.nativ/', '/.worktrees/', '/AGENTS.md', '/CLAUDE.md', '/GEMINI.md',
|
|
56
|
+
...AGENT_FILES.map((f) => `/.claude/agents/${f}`), '/.mcp.json', '/.claude/settings.json',
|
|
57
|
+
];
|
|
58
|
+
return canonical.filter((p) => disk.includes(p) || created.has(p));
|
|
59
|
+
}
|
|
47
60
|
/** Computes what `nativ setup` would do, without touching disk. */
|
|
48
61
|
export function planSetup(rootArg, options = {}) {
|
|
49
62
|
const root = path.resolve(rootArg);
|
|
@@ -55,6 +68,7 @@ export function planSetup(rootArg, options = {}) {
|
|
|
55
68
|
const changes = [];
|
|
56
69
|
const warnings = [];
|
|
57
70
|
const writes = new Map();
|
|
71
|
+
let visibility = options.gitVisibility ?? 'hidden';
|
|
58
72
|
if (!invocation.portable) {
|
|
59
73
|
warnings.push('nativ is not on PATH, so the generated config points at this machine\'s install. ' +
|
|
60
74
|
'Install it globally (npm i -g @njeybe/nativ) or pass --command "npx -y @njeybe/nativ" so the config works for your team.');
|
|
@@ -143,7 +157,7 @@ export function planSetup(rootArg, options = {}) {
|
|
|
143
157
|
for (const [file, title] of [['CLAUDE.md', 'Claude Code'], ['GEMINI.md', 'Gemini / Antigravity']]) {
|
|
144
158
|
const existing = readText(path.join(root, file));
|
|
145
159
|
if (existing === null)
|
|
146
|
-
record(file, null, `# ${title} directive\n\n@AGENTS.md\n`);
|
|
160
|
+
record(file, null, stampManaged(`# ${title} directive\n\n@AGENTS.md\n`));
|
|
147
161
|
else
|
|
148
162
|
changes.push({ path: file, action: 'unchanged', detail: /AGENTS\.md/.test(existing) ? undefined : 'exists; does not reference AGENTS.md' });
|
|
149
163
|
}
|
|
@@ -160,9 +174,21 @@ export function planSetup(rootArg, options = {}) {
|
|
|
160
174
|
next.enforcement = loadEnforcementMode(root);
|
|
161
175
|
detail = `enforcement=${String(next.enforcement)}`;
|
|
162
176
|
}
|
|
177
|
+
if (options.gitVisibility && next.gitVisibility !== options.gitVisibility) {
|
|
178
|
+
next.gitVisibility = options.gitVisibility;
|
|
179
|
+
detail = [detail, `gitVisibility=${options.gitVisibility}`].filter(Boolean).join(', ');
|
|
180
|
+
}
|
|
163
181
|
record('.nativ/config.json', config.text, detail || config.text === null ? serializeJson(next) : (config.text ?? ''), detail);
|
|
182
|
+
visibility = options.gitVisibility ?? (next.gitVisibility === 'shared' ? 'shared' : 'hidden');
|
|
164
183
|
}
|
|
165
|
-
|
|
184
|
+
// .gitignore: the managed block, from what is on disk plus what this run creates
|
|
185
|
+
const paths = blockPaths(root, visibility, changes);
|
|
186
|
+
const ignore = readText(path.join(root, '.gitignore'));
|
|
187
|
+
record('.gitignore', ignore, applyBlock(ignore, paths), 'nativ block');
|
|
188
|
+
const gitRmCommand = trackedNativPaths(root, paths).command;
|
|
189
|
+
if (gitRmCommand)
|
|
190
|
+
warnings.push(`nativ files are already tracked by git. To stop tracking them, run: ${gitRmCommand}`);
|
|
191
|
+
return { root, invocation, changes, warnings, writes, gitRmCommand: gitRmCommand || undefined };
|
|
166
192
|
}
|
|
167
193
|
/** Writes the planned differences. Idempotent: applying twice changes nothing the second time. */
|
|
168
194
|
export function applySetup(rootArg, options = {}) {
|
|
@@ -176,5 +202,12 @@ export function applySetup(rootArg, options = {}) {
|
|
|
176
202
|
fs.renameSync(tmp, file);
|
|
177
203
|
}
|
|
178
204
|
}
|
|
179
|
-
return {
|
|
205
|
+
return {
|
|
206
|
+
root: plan.root,
|
|
207
|
+
dryRun: options.dryRun === true,
|
|
208
|
+
invocation: plan.invocation,
|
|
209
|
+
changes: plan.changes,
|
|
210
|
+
warnings: plan.warnings,
|
|
211
|
+
gitRmCommand: plan.gitRmCommand,
|
|
212
|
+
};
|
|
180
213
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { EnforcementMode } from '../enforcement.js';
|
|
2
|
+
import type { GitVisibility } from './gitignore-block.js';
|
|
2
3
|
export declare const MCP_SERVER_NAME = "nativ";
|
|
3
4
|
export declare const AGENT_FILES: readonly ["architect.md", "worker.md", "verifier.md", "explorer.md"];
|
|
4
5
|
export declare const MANAGED_PREFIX = "<!-- nativ:managed sha256=";
|
|
@@ -27,6 +28,8 @@ export interface SetupOptions {
|
|
|
27
28
|
enforcement?: EnforcementMode;
|
|
28
29
|
/** Also overwrite agent files that were edited by hand or are not nativ's. */
|
|
29
30
|
force?: boolean;
|
|
31
|
+
/** Whether nativ's files stay out of git (hidden, the default) or are shared with the team. */
|
|
32
|
+
gitVisibility?: GitVisibility;
|
|
30
33
|
hasBinary?: (name: string) => boolean;
|
|
31
34
|
templatesDir?: string;
|
|
32
35
|
}
|
|
@@ -37,6 +40,8 @@ export interface SetupPlan {
|
|
|
37
40
|
warnings: string[];
|
|
38
41
|
/** Full new content per changed path, keyed by the same relative path. */
|
|
39
42
|
writes: Map<string, string>;
|
|
43
|
+
/** `git rm --cached` command for nativ files git already tracks; never run by nativ. */
|
|
44
|
+
gitRmCommand?: string;
|
|
40
45
|
}
|
|
41
46
|
export interface SetupResult {
|
|
42
47
|
root: string;
|
|
@@ -44,6 +49,7 @@ export interface SetupResult {
|
|
|
44
49
|
invocation: CliInvocation;
|
|
45
50
|
changes: AssetChange[];
|
|
46
51
|
warnings: string[];
|
|
52
|
+
gitRmCommand?: string;
|
|
47
53
|
}
|
|
48
54
|
export interface TemplateFilePlan {
|
|
49
55
|
action: AssetAction;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export declare const CLEAN_TREE_PREFIX = "nativ-verify-";
|
|
2
|
+
export type CleanTree = {
|
|
3
|
+
ok: true;
|
|
4
|
+
dir: string;
|
|
5
|
+
dispose: () => Promise<void>;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function disposeTree(root: string, dir: string, names: string[], unlink?: (dir: string, name: string) => boolean): Promise<void>;
|
|
11
|
+
export declare function createCleanTree(root: string, opts?: {
|
|
12
|
+
links?: string[];
|
|
13
|
+
}): Promise<CleanTree>;
|
|
14
|
+
export declare function sweepStaleCleanTrees(root: string): Promise<number>;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { linkWorktreeDir, safeUnlinkWorktreeDir } from '../root-resolver.js';
|
|
6
|
+
export const CLEAN_TREE_PREFIX = 'nativ-verify-';
|
|
7
|
+
const STALE_MS = 60 * 60 * 1000;
|
|
8
|
+
function git(cwd, args) {
|
|
9
|
+
const opts = { cwd, maxBuffer: 64 * 1024 * 1024, windowsHide: true };
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
execFile('git', args, opts, (err, stdout) => {
|
|
12
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
13
|
+
resolve({ code, out: String(stdout ?? '') });
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
async function removeDir(dir) {
|
|
19
|
+
for (let i = 0; i < 6; i++) {
|
|
20
|
+
try {
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
if (!fs.existsSync(dir))
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
const code = e.code;
|
|
27
|
+
if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY')
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await sleep(150 * (i + 1));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Links go first: removing a tree can follow a junction into the real folder.
|
|
34
|
+
function linkRemains(dir, names) {
|
|
35
|
+
return names.some((n) => {
|
|
36
|
+
try {
|
|
37
|
+
return fs.lstatSync(path.join(dir, n)).isSymbolicLink();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// unlink is injectable so tests can simulate a link that refuses to go away.
|
|
45
|
+
export async function disposeTree(root, dir, names, unlink = safeUnlinkWorktreeDir) {
|
|
46
|
+
try {
|
|
47
|
+
for (const n of names)
|
|
48
|
+
unlink(dir, n);
|
|
49
|
+
// A surviving link means a recursive delete could reach the real folder: leave it for the sweep.
|
|
50
|
+
if (linkRemains(dir, names))
|
|
51
|
+
return;
|
|
52
|
+
for (let i = 0; i < 4; i++) {
|
|
53
|
+
const r = await git(root, ['worktree', 'remove', '--force', dir]);
|
|
54
|
+
if (r.code === 0 || !fs.existsSync(dir))
|
|
55
|
+
break;
|
|
56
|
+
await sleep(150 * (i + 1));
|
|
57
|
+
}
|
|
58
|
+
await removeDir(dir);
|
|
59
|
+
await git(root, ['worktree', 'prune']);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Cleanup never throws.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function copyUntracked(root, dir) {
|
|
66
|
+
const r = await git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
67
|
+
for (const rel of r.out.split('\0').filter(Boolean)) {
|
|
68
|
+
const from = path.join(root, rel);
|
|
69
|
+
const to = path.join(dir, rel);
|
|
70
|
+
try {
|
|
71
|
+
if (!fs.statSync(from).isFile())
|
|
72
|
+
continue;
|
|
73
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
74
|
+
fs.copyFileSync(from, to);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A file that vanished mid-copy is not worth failing the tree.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export async function createCleanTree(root, opts = {}) {
|
|
82
|
+
const inRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
83
|
+
if (inRepo.code !== 0)
|
|
84
|
+
return { ok: false, reason: 'clean isolation needs a git repository' };
|
|
85
|
+
const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
|
|
86
|
+
if (head.code !== 0)
|
|
87
|
+
return { ok: false, reason: 'clean isolation needs at least one commit' };
|
|
88
|
+
const stash = await git(root, ['stash', 'create']);
|
|
89
|
+
const commit = stash.code === 0 && stash.out.trim() ? stash.out.trim() : head.out.trim();
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), CLEAN_TREE_PREFIX));
|
|
91
|
+
fs.rmdirSync(dir);
|
|
92
|
+
const args = ['-c', 'core.autocrlf=false', 'worktree', 'add', '--detach', dir, commit];
|
|
93
|
+
const add = await git(root, args);
|
|
94
|
+
if (add.code !== 0) {
|
|
95
|
+
await removeDir(dir);
|
|
96
|
+
return { ok: false, reason: 'could not create the clean tree' };
|
|
97
|
+
}
|
|
98
|
+
await copyUntracked(root, dir);
|
|
99
|
+
const linked = (opts.links ?? []).filter((n) => linkWorktreeDir(dir, root, n));
|
|
100
|
+
return { ok: true, dir, dispose: () => disposeTree(root, dir, linked) };
|
|
101
|
+
}
|
|
102
|
+
function ownedBy(root, dir) {
|
|
103
|
+
try {
|
|
104
|
+
const text = fs.readFileSync(path.join(dir, '.git'), 'utf8');
|
|
105
|
+
const gitdir = text.replace(/^gitdir:\s*/, '').trim();
|
|
106
|
+
const main = realOf(path.resolve(root, '.git')).toLowerCase();
|
|
107
|
+
return realOf(path.resolve(gitdir)).toLowerCase().startsWith(main);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Real paths make Windows short (8.3) and aliased paths compare equal.
|
|
114
|
+
function realOf(p) {
|
|
115
|
+
try {
|
|
116
|
+
return fs.realpathSync.native(p);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return p;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function linkNames(dir) {
|
|
123
|
+
return fs.readdirSync(dir).filter((n) => fs.lstatSync(path.join(dir, n)).isSymbolicLink());
|
|
124
|
+
}
|
|
125
|
+
export async function sweepStaleCleanTrees(root) {
|
|
126
|
+
let removed = 0;
|
|
127
|
+
try {
|
|
128
|
+
const tmp = os.tmpdir();
|
|
129
|
+
for (const name of fs.readdirSync(tmp)) {
|
|
130
|
+
if (!name.startsWith(CLEAN_TREE_PREFIX))
|
|
131
|
+
continue;
|
|
132
|
+
const dir = path.join(tmp, name);
|
|
133
|
+
try {
|
|
134
|
+
if (Date.now() - fs.statSync(dir).mtimeMs < STALE_MS)
|
|
135
|
+
continue;
|
|
136
|
+
if (!ownedBy(root, dir))
|
|
137
|
+
continue;
|
|
138
|
+
await disposeTree(root, dir, linkNames(dir));
|
|
139
|
+
if (!fs.existsSync(dir))
|
|
140
|
+
removed++;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Skip entries we cannot read.
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Nothing to sweep.
|
|
149
|
+
}
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
@@ -2,4 +2,6 @@ import type { VerificationResult } from './types.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Execute a verification command in the workspace directory with timeout and output capture.
|
|
4
4
|
*/
|
|
5
|
-
export declare function executeVerification(command: string, cwd: string, timeoutMs?: number
|
|
5
|
+
export declare function executeVerification(command: string, cwd: string, timeoutMs?: number, options?: {
|
|
6
|
+
env?: NodeJS.ProcessEnv;
|
|
7
|
+
}): Promise<VerificationResult>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawn, execFile } from 'node:child_process';
|
|
2
|
+
const MAX_OUTPUT = 10 * 1024 * 1024;
|
|
2
3
|
/**
|
|
3
4
|
* Execute a verification command in the workspace directory with timeout and output capture.
|
|
4
5
|
*/
|
|
5
|
-
export async function executeVerification(command, cwd, timeoutMs = 120_000) {
|
|
6
|
+
export async function executeVerification(command, cwd, timeoutMs = 120_000, options = {}) {
|
|
6
7
|
const trimmed = (command || '').trim();
|
|
7
8
|
// Treat empty, 'none', or 'skip' as an informational pass
|
|
8
9
|
if (!trimmed || trimmed.toLowerCase() === 'none' || trimmed.toLowerCase() === 'skip') {
|
|
@@ -26,37 +27,59 @@ export async function executeVerification(command, cwd, timeoutMs = 120_000) {
|
|
|
26
27
|
}
|
|
27
28
|
const start = Date.now();
|
|
28
29
|
return new Promise((resolve) => {
|
|
29
|
-
|
|
30
|
+
let timedOut = false;
|
|
31
|
+
let out = '';
|
|
32
|
+
let errOut = '';
|
|
33
|
+
const child = spawn(finalCommand, {
|
|
30
34
|
cwd,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
shell: true,
|
|
36
|
+
env: options.env ?? { ...process.env, NATIV_VERIFY_SANDBOX: '1' },
|
|
37
|
+
detached: process.platform !== 'win32',
|
|
38
|
+
windowsHide: true,
|
|
39
|
+
});
|
|
40
|
+
const cap = (cur, chunk) => cur.length < MAX_OUTPUT ? cur + chunk.toString() : cur;
|
|
41
|
+
child.stdout?.on('data', (c) => (out = cap(out, c)));
|
|
42
|
+
child.stderr?.on('data', (c) => (errOut = cap(errOut, c)));
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
killTree(child.pid);
|
|
46
|
+
}, timeoutMs);
|
|
47
|
+
const finish = (code, message) => {
|
|
48
|
+
clearTimeout(timer);
|
|
35
49
|
const durationMs = Date.now() - start;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const exitCode = typeof err.code === 'number' ? err.code : 1;
|
|
40
|
-
resolve({
|
|
41
|
-
success: false,
|
|
42
|
-
exitCode,
|
|
43
|
-
command: trimmed,
|
|
44
|
-
stdout: out,
|
|
45
|
-
stderr: errOut || err.message,
|
|
46
|
-
durationMs,
|
|
47
|
-
error: err.killed ? `Command timed out after ${timeoutMs}ms` : err.message,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
resolve({
|
|
52
|
-
success: true,
|
|
53
|
-
exitCode: 0,
|
|
54
|
-
command: trimmed,
|
|
55
|
-
stdout: out,
|
|
56
|
-
stderr: errOut,
|
|
57
|
-
durationMs,
|
|
58
|
-
});
|
|
50
|
+
if (code === 0 && !message && !timedOut) {
|
|
51
|
+
resolve({ success: true, exitCode: 0, command: trimmed, stdout: out, stderr: errOut, durationMs });
|
|
52
|
+
return;
|
|
59
53
|
}
|
|
60
|
-
|
|
54
|
+
const error = timedOut
|
|
55
|
+
? `Command timed out after ${timeoutMs}ms`
|
|
56
|
+
: message ?? `Command failed: ${trimmed}`;
|
|
57
|
+
resolve({
|
|
58
|
+
success: false,
|
|
59
|
+
exitCode: typeof code === 'number' && code !== 0 ? code : 1,
|
|
60
|
+
command: trimmed,
|
|
61
|
+
stdout: out,
|
|
62
|
+
stderr: errOut || error,
|
|
63
|
+
durationMs,
|
|
64
|
+
error,
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
child.on('error', (e) => finish(null, e.message));
|
|
68
|
+
child.on('close', (code) => finish(code));
|
|
61
69
|
});
|
|
62
70
|
}
|
|
71
|
+
/** Kills the process and all its descendants. */
|
|
72
|
+
function killTree(pid) {
|
|
73
|
+
if (!pid)
|
|
74
|
+
return;
|
|
75
|
+
if (process.platform === 'win32') {
|
|
76
|
+
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], () => { });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
process.kill(-pid, 'SIGKILL');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Group already gone
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MasterPlanTask } from '../../scanner/types.js';
|
|
2
|
+
import type { PhasesOutcome, VerificationResult } from './types.js';
|
|
3
|
+
/** Where a verification runs: the real tree, or a disposable clean copy. */
|
|
4
|
+
export interface IsolationPlan {
|
|
5
|
+
mode: 'none' | 'clean';
|
|
6
|
+
dir?: string;
|
|
7
|
+
env?: NodeJS.ProcessEnv;
|
|
8
|
+
warnings: string[];
|
|
9
|
+
dispose?: () => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare const NO_ISOLATION: IsolationPlan;
|
|
12
|
+
export declare function needsRun(commands: string[], configDir: string, sharedPhases: boolean): boolean;
|
|
13
|
+
export declare function openIsolation(cwd: string, configDir: string, wanted: 'none' | 'clean'): Promise<IsolationPlan>;
|
|
14
|
+
/** Like runVerifyPhases, but with an explicit environment for each phase. */
|
|
15
|
+
export declare function runPhasesIn(dir: string, configDir: string, timeout: number | undefined, env?: NodeJS.ProcessEnv): Promise<PhasesOutcome>;
|
|
16
|
+
export interface RunOptions {
|
|
17
|
+
cwd: string;
|
|
18
|
+
configDir?: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
phases?: PhasesOutcome;
|
|
21
|
+
isolation?: 'none' | 'clean';
|
|
22
|
+
plan?: IsolationPlan;
|
|
23
|
+
}
|
|
24
|
+
export declare function runInPlan(task: MasterPlanTask, o: RunOptions, plan: IsolationPlan): Promise<VerificationResult>;
|
|
25
|
+
export declare function diagnose(task: MasterPlanTask, o: RunOptions, failed: VerificationResult, failure: 'warn' | 'block'): Promise<VerificationResult>;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { loadVerifySettings } from '../profiles.js';
|
|
2
|
+
import { resolveMainRoot } from '../root-resolver.js';
|
|
3
|
+
import { executeVerification } from './executor.js';
|
|
4
|
+
import { createCleanTree, sweepStaleCleanTrees } from './clean-tree.js';
|
|
5
|
+
import { lintPortability } from './portability-lint.js';
|
|
6
|
+
import { buildVerifyEnv } from './verify-env.js';
|
|
7
|
+
import { loadVerifyPhases, runVerifyPhases } from './phases.js';
|
|
8
|
+
export const NO_ISOLATION = { mode: 'none', warnings: [] };
|
|
9
|
+
function isSkipped(command) {
|
|
10
|
+
const t = (command || '').trim().toLowerCase();
|
|
11
|
+
return !t || t === 'none' || t === 'skip';
|
|
12
|
+
}
|
|
13
|
+
export function needsRun(commands, configDir, sharedPhases) {
|
|
14
|
+
if (commands.some((c) => !isSkipped(c)))
|
|
15
|
+
return true;
|
|
16
|
+
return !sharedPhases && loadVerifyPhases(configDir).phases.length > 0;
|
|
17
|
+
}
|
|
18
|
+
export async function openIsolation(cwd, configDir, wanted) {
|
|
19
|
+
if (wanted !== 'clean')
|
|
20
|
+
return NO_ISOLATION;
|
|
21
|
+
await sweepStaleCleanTrees(cwd);
|
|
22
|
+
const settings = loadVerifySettings(resolveMainRoot(configDir));
|
|
23
|
+
const tree = await createCleanTree(cwd, { links: settings.verifyLinks });
|
|
24
|
+
if (!tree.ok) {
|
|
25
|
+
return { mode: 'none', warnings: [`Clean isolation unavailable (${tree.reason}); ran in place.`] };
|
|
26
|
+
}
|
|
27
|
+
const env = buildVerifyEnv(settings.verifyEnv);
|
|
28
|
+
return { mode: 'clean', dir: tree.dir, env, warnings: [], dispose: tree.dispose };
|
|
29
|
+
}
|
|
30
|
+
/** Like runVerifyPhases, but with an explicit environment for each phase. */
|
|
31
|
+
export async function runPhasesIn(dir, configDir, timeout, env) {
|
|
32
|
+
const config = loadVerifyPhases(configDir);
|
|
33
|
+
if (!env || config.error)
|
|
34
|
+
return runVerifyPhases(dir, configDir, timeout);
|
|
35
|
+
const reports = [];
|
|
36
|
+
for (const phase of config.phases) {
|
|
37
|
+
const res = await executeVerification(phase.run, dir, timeout, { env });
|
|
38
|
+
reports.push({ name: phase.name, command: res.command, success: res.success, durationMs: res.durationMs });
|
|
39
|
+
if (!res.success) {
|
|
40
|
+
const cause = res.error ? `: ${res.error.trim()}` : '';
|
|
41
|
+
return { reports, failure: { ...res, phases: reports, error: `Phase "${phase.name}" failed${cause}` } };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { reports };
|
|
45
|
+
}
|
|
46
|
+
// The phases then the task command, in the plan's directory; code-shape is not part of this.
|
|
47
|
+
export async function runInPlan(task, o, plan) {
|
|
48
|
+
const configDir = o.configDir ?? o.cwd;
|
|
49
|
+
const dir = plan.dir ?? o.cwd;
|
|
50
|
+
const outcome = o.phases ?? (await runPhasesIn(dir, configDir, o.timeout, plan.env));
|
|
51
|
+
// In a batch the shared phase time is counted once, by the batch, not again for every task.
|
|
52
|
+
if (outcome.failure) {
|
|
53
|
+
return { ...outcome.failure, phases: outcome.reports, ...(o.phases ? { durationMs: 0 } : {}) };
|
|
54
|
+
}
|
|
55
|
+
const raw = await executeVerification(task.verificationCommand, dir, o.timeout, { env: plan.env });
|
|
56
|
+
if (outcome.reports.length) {
|
|
57
|
+
raw.phases = outcome.reports;
|
|
58
|
+
raw.durationMs += o.phases ? 0 : outcome.reports.reduce((sum, r) => sum + r.durationMs, 0);
|
|
59
|
+
raw.skipped = false;
|
|
60
|
+
}
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
// A clean failure is re-run in place only to learn whether the clean copy is the cause.
|
|
64
|
+
export async function diagnose(task, o, failed, failure) {
|
|
65
|
+
if (failed.error?.startsWith('Command timed out'))
|
|
66
|
+
return failed;
|
|
67
|
+
const local = await runInPlan(task, { ...o, phases: undefined }, NO_ISOLATION);
|
|
68
|
+
if (!local.success)
|
|
69
|
+
return failed;
|
|
70
|
+
const base = 'Passes only on this machine: it failed in a clean copy of the project.';
|
|
71
|
+
const note = [base, ...lintPortability(o.cwd, task.targetFiles || [])].join(' ');
|
|
72
|
+
if (failure === 'warn') {
|
|
73
|
+
return { ...local, isolation: 'clean', warnings: [...(failed.warnings ?? []), note] };
|
|
74
|
+
}
|
|
75
|
+
const error = `${failed.error ?? 'Verification failed'}. ${note} Isolation failure is set to block.`;
|
|
76
|
+
return { ...failed, error, warnings: [...(failed.warnings ?? []), note] };
|
|
77
|
+
}
|