@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +26 -1
  3. package/dist/commands/check.d.ts +3 -0
  4. package/dist/commands/check.js +36 -0
  5. package/dist/commands/doctor.js +7 -1
  6. package/dist/commands/init.d.ts +1 -0
  7. package/dist/commands/init.js +1 -1
  8. package/dist/commands/setup.d.ts +1 -0
  9. package/dist/commands/setup.js +1 -0
  10. package/dist/commands/task/task-add.js +49 -6
  11. package/dist/commands/task/task-common.d.ts +6 -0
  12. package/dist/commands/task/task-lifecycle.d.ts +3 -1
  13. package/dist/commands/task/task-lifecycle.js +40 -2
  14. package/dist/commands/update.d.ts +1 -0
  15. package/dist/commands/update.js +1 -1
  16. package/dist/commands/verify.d.ts +7 -0
  17. package/dist/commands/verify.js +26 -2
  18. package/dist/core/checks/docs-check.d.ts +18 -0
  19. package/dist/core/checks/docs-check.js +186 -0
  20. package/dist/core/code-shape.d.ts +7 -2
  21. package/dist/core/code-shape.js +46 -16
  22. package/dist/core/env-reads.d.ts +15 -0
  23. package/dist/core/env-reads.js +126 -0
  24. package/dist/core/human-gate.js +6 -3
  25. package/dist/core/profiles.d.ts +8 -0
  26. package/dist/core/profiles.js +43 -2
  27. package/dist/core/root-resolver.d.ts +4 -0
  28. package/dist/core/root-resolver.js +32 -0
  29. package/dist/core/setup/gitignore-block.d.ts +17 -0
  30. package/dist/core/setup/gitignore-block.js +182 -0
  31. package/dist/core/setup/setup-planner.js +36 -3
  32. package/dist/core/setup/types.d.ts +6 -0
  33. package/dist/core/verifier/clean-tree.d.ts +14 -0
  34. package/dist/core/verifier/clean-tree.js +151 -0
  35. package/dist/core/verifier/executor.d.ts +3 -1
  36. package/dist/core/verifier/executor.js +54 -31
  37. package/dist/core/verifier/isolation.d.ts +25 -0
  38. package/dist/core/verifier/isolation.js +77 -0
  39. package/dist/core/verifier/portability-lint.d.ts +2 -0
  40. package/dist/core/verifier/portability-lint.js +64 -0
  41. package/dist/core/verifier/skip-policy.d.ts +8 -0
  42. package/dist/core/verifier/skip-policy.js +13 -0
  43. package/dist/core/verifier/task-verifier.d.ts +6 -8
  44. package/dist/core/verifier/task-verifier.js +68 -39
  45. package/dist/core/verifier/types.d.ts +4 -0
  46. package/dist/core/verifier/verify-env.d.ts +2 -0
  47. package/dist/core/verifier/verify-env.js +19 -0
  48. package/dist/index.js +33 -4
  49. package/dist/mcp/server.js +9 -2
  50. package/dist/scanner/types.d.ts +4 -0
  51. package/dist/scanner/types.js +9 -0
  52. package/dist/server/routes/task-routes.d.ts +1 -0
  53. package/package.json +1 -1
  54. package/plugin/.claude-plugin/plugin.json +1 -1
  55. package/plugin/agents/architect.md +14 -7
  56. package/plugin/agents/verifier.md +16 -14
  57. package/plugin/skills/nativ/SKILL.md +11 -8
  58. package/templates/AGENTS.md +11 -8
  59. package/templates/claude-agents/architect.md +14 -7
  60. package/templates/claude-agents/verifier.md +16 -14
  61. package/templates/dot-ai/subagents/qa-tester.md +14 -13
@@ -0,0 +1,186 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ const EXTS = [
5
+ '.ts', '.js', '.mjs', '.cjs', '.json', '.md', '.sh', '.py', '.yml', '.yaml', '.toml', '.sql', '.example',
6
+ ];
7
+ const TOKEN = /`([^`\n]+)`/g;
8
+ const PLACEHOLDER = /^<[\w-]+>$/;
9
+ const SETTING = /^(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+|[A-Z][A-Z0-9_]*(?==))/;
10
+ const SETTING_USE = /^(?:([A-Z][A-Z0-9_]*)=|\$\{?([A-Z][A-Z0-9_]*)\}?$)/;
11
+ const SCOPED = /^@[\w.-]+\/[\w.-]+$/;
12
+ // Created by nativ in the user's project, so absent here is fine.
13
+ const RUNTIME_DIRS = ['.ai', '.nativ', '.worktrees', '.claude', 'tests/contract'];
14
+ const RUNTIME_FILES = new Set([
15
+ 'db_schema.json', 'api_contracts.json', 'master_plan.json', 'ui_specs.md', 'context.md', '.env.example',
16
+ 'AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.mcp.json', 'SKILL.md', 'DIGEST.md',
17
+ ]);
18
+ const CODE_EXTS = /\.(?:ts|js|mjs|cjs|py|sh)$/;
19
+ const codeCache = new Map();
20
+ /** Source text of tracked code files, joined; used to find string literals. */
21
+ function codeText(root) {
22
+ const hit = codeCache.get(root);
23
+ if (hit !== undefined)
24
+ return hit;
25
+ let files = [];
26
+ try {
27
+ const opts = { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] };
28
+ files = execFileSync('git', ['ls-files'], { ...opts, stdio: [...opts.stdio] })
29
+ .split(/\r?\n/).filter((f) => CODE_EXTS.test(f));
30
+ }
31
+ catch {
32
+ files = [];
33
+ }
34
+ const parts = [];
35
+ for (const f of files) {
36
+ try {
37
+ parts.push(fs.readFileSync(path.join(root, f), 'utf8'));
38
+ }
39
+ catch { /* removed file */ }
40
+ }
41
+ const text = parts.join('\n');
42
+ codeCache.set(root, text);
43
+ return text;
44
+ }
45
+ function hasLiteral(name, root) {
46
+ return new RegExp(`(['"\`])${name}\\1`).test(codeText(root));
47
+ }
48
+ function isRuntimePath(t) {
49
+ const clean = t.replace(/^\.\//, '').replace(/\/$/, '');
50
+ if (RUNTIME_DIRS.some((d) => clean === d || clean.startsWith(`${d}/`)))
51
+ return true;
52
+ if (/^[\w.-]+\/$/.test(t))
53
+ return true; // a bare folder name is an example
54
+ return !clean.includes('/') && RUNTIME_FILES.has(clean);
55
+ }
56
+ const PATHY = /^[\w.@~-][\w./@~-]*$/;
57
+ function isPathLike(t) {
58
+ if (t.includes('://') || SCOPED.test(t) || !PATHY.test(t) || t.startsWith('-'))
59
+ return false;
60
+ return t.includes('/') || EXTS.some((e) => t.toLowerCase().endsWith(e));
61
+ }
62
+ function findFile(t, root, docDir) {
63
+ const clean = t.replace(/^\.?\//, '');
64
+ for (const base of [root, docDir]) {
65
+ const full = path.join(base, clean);
66
+ if (fs.existsSync(full) && fs.statSync(full).isFile())
67
+ return full;
68
+ }
69
+ return null;
70
+ }
71
+ function pathExists(t, root, docDir) {
72
+ const clean = t.replace(/^\.?\//, '').replace(/\/$/, '');
73
+ return [root, docDir].some((b) => fs.existsSync(path.join(b, clean)));
74
+ }
75
+ function checkFlags(words, program) {
76
+ let spec = program;
77
+ const names = [];
78
+ for (const w of words.slice(1)) {
79
+ if (w.startsWith('-') || !spec.subs[w])
80
+ break;
81
+ spec = spec.subs[w];
82
+ names.push(w);
83
+ }
84
+ if (names.length === 0)
85
+ return null;
86
+ for (const w of words.slice(1)) {
87
+ if (!w.startsWith('-'))
88
+ continue;
89
+ const flag = w.split('=')[0];
90
+ if (['--help', '-h'].includes(flag) || spec.flags.includes(flag))
91
+ continue;
92
+ return `nativ ${names.join(' ')} has no option ${flag}`;
93
+ }
94
+ return null;
95
+ }
96
+ /** Lines of prose with their paragraph index; fenced code blocks are skipped. */
97
+ function proseLines(text) {
98
+ const out = [];
99
+ let fenced = false;
100
+ let para = 0;
101
+ let skipNext = false;
102
+ let ignoring = false;
103
+ text.split(/\r?\n/).forEach((raw, i) => {
104
+ if (skipNext) {
105
+ skipNext = false;
106
+ return;
107
+ }
108
+ if (/<!--\s*nativ-check-ignore-end\s*-->/.test(raw)) {
109
+ ignoring = false;
110
+ return;
111
+ }
112
+ if (/<!--\s*nativ-check-ignore-start\s*-->/.test(raw)) {
113
+ ignoring = true;
114
+ return;
115
+ }
116
+ if (/^\s*<!--\s*nativ-check-ignore\s*-->\s*$/.test(raw)) {
117
+ skipNext = true;
118
+ return;
119
+ }
120
+ if (ignoring)
121
+ return;
122
+ if (/^\s*(```|~~~)/.test(raw)) {
123
+ fenced = !fenced;
124
+ return;
125
+ }
126
+ if (fenced)
127
+ return;
128
+ if (raw.trim() === '') {
129
+ para += 1;
130
+ return;
131
+ }
132
+ out.push({ line: i + 1, text: raw, para });
133
+ });
134
+ return out;
135
+ }
136
+ export function checkDocText(text, docFile, ctx) {
137
+ const findings = [];
138
+ const docDir = path.dirname(path.resolve(ctx.root, docFile));
139
+ const rel = path.relative(ctx.root, path.resolve(ctx.root, docFile)).split(path.sep).join('/');
140
+ const lines = proseLines(text);
141
+ const add = (line, kind, token, message) => findings.push({ file: rel, line, kind, token, message });
142
+ // The file a paragraph names: the first path-like token that exists.
143
+ const paraFile = new Map();
144
+ for (const l of lines) {
145
+ for (const m of l.text.matchAll(TOKEN)) {
146
+ const t = m[1].trim();
147
+ if (paraFile.has(l.para) || !isPathLike(t))
148
+ continue;
149
+ const f = findFile(t, ctx.root, docDir);
150
+ if (f)
151
+ paraFile.set(l.para, f);
152
+ }
153
+ }
154
+ for (const l of lines) {
155
+ for (const m of l.text.matchAll(TOKEN)) {
156
+ const t = m[1].trim();
157
+ const words = t.split(/\s+/);
158
+ if (words[0] === 'nativ') {
159
+ const bare = t.replace(/"[^"]*"|'[^']*'/g, ' ').trim().split(/\s+/);
160
+ const msg = checkFlags(bare, ctx.program);
161
+ if (msg)
162
+ add(l.line, 'flag', t, msg);
163
+ }
164
+ else if (PLACEHOLDER.test(t)) {
165
+ const f = paraFile.get(l.para);
166
+ if (f && !fs.readFileSync(f, 'utf8').includes(t)) {
167
+ const name = path.basename(f);
168
+ add(l.line, 'placeholder', t, `${t} does not appear in ${name}, which this paragraph describes`);
169
+ }
170
+ }
171
+ else if (isPathLike(t)) {
172
+ if (!isRuntimePath(t) && !pathExists(t, ctx.root, docDir))
173
+ add(l.line, 'path', t, `${t} does not exist`);
174
+ }
175
+ else {
176
+ const use = SETTING_USE.exec(t);
177
+ const name = use ? use[1] ?? use[2] : '';
178
+ const msg = `${name} is not read by the code or listed in .env.example`;
179
+ if (name && !ctx.envNames.has(name) && !hasLiteral(name, ctx.root)) {
180
+ add(l.line, 'setting', t, msg);
181
+ }
182
+ }
183
+ }
184
+ }
185
+ return findings;
186
+ }
@@ -8,19 +8,24 @@ export interface CodeShapeSettings {
8
8
  mode: CodeShapeMode;
9
9
  maxLineLength: number;
10
10
  maxCommentLines: number;
11
+ configFallback: CodeShapeMode;
11
12
  }
12
13
  export interface CodeShapeIssue {
13
14
  file: string;
14
15
  line: number;
15
- kind: 'long-line' | 'long-comment';
16
+ kind: 'long-line' | 'long-comment' | 'config-fallback';
17
+ category: 'style' | 'config';
16
18
  message: string;
17
19
  }
18
20
  export interface CodeShapeReport {
19
21
  mode: CodeShapeMode;
22
+ configMode: CodeShapeMode;
20
23
  issues: CodeShapeIssue[];
21
24
  }
22
25
  export declare function loadCodeShapeSettings(targetDir: string): CodeShapeSettings;
23
26
  export declare function checkFileShape(file: string, text: string, s: CodeShapeSettings): CodeShapeIssue[];
27
+ /** Env reads that silently fall back to an empty value; reported as config findings. */
28
+ export declare function checkConfigFallbacks(file: string, text: string): CodeShapeIssue[];
24
29
  export declare function checkCodeShape(targetDir: string, targetFiles?: string[]): CodeShapeReport;
25
- /** Short plain-language summary; empty string when there is nothing to report. */
30
+ /** Short plain-language summary, config findings first; empty string when nothing to report. */
26
31
  export declare function formatCodeShapeReport(report: CodeShapeReport): string;
@@ -2,7 +2,9 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseJsonLoose } from './enforcement.js';
4
4
  import { resolveMainRoot } from './root-resolver.js';
5
- const DEFAULTS = { mode: 'warn', maxLineLength: 120, maxCommentLines: 2 };
5
+ import { scanEnvReads } from './env-reads.js';
6
+ const DEFAULTS = { mode: 'warn', maxLineLength: 120, maxCommentLines: 2,
7
+ configFallback: 'warn' };
6
8
  const SOURCE_EXTENSIONS = new Set([
7
9
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.kt', '.dart',
8
10
  '.c', '.cc', '.cpp', '.h', '.cs', '.rb', '.php', '.swift', '.vue', '.svelte', '.css', '.scss',
@@ -18,6 +20,9 @@ export function loadCodeShapeSettings(targetDir) {
18
20
  return settings;
19
21
  if (['warn', 'block', 'off'].includes(style.mode))
20
22
  settings.mode = style.mode;
23
+ if (['warn', 'block', 'off'].includes(style.configFallback)) {
24
+ settings.configFallback = style.configFallback;
25
+ }
21
26
  if (Number.isInteger(style.maxLineLength) && style.maxLineLength > 0) {
22
27
  settings.maxLineLength = style.maxLineLength;
23
28
  }
@@ -79,7 +84,7 @@ export function checkFileShape(file, text, s) {
79
84
  lines.forEach((line, i) => {
80
85
  if (line.length > s.maxLineLength) {
81
86
  issues.push({
82
- file, line: i + 1, kind: 'long-line',
87
+ file, line: i + 1, kind: 'long-line', category: 'style',
83
88
  message: `line is ${line.length} characters (max ${s.maxLineLength})`,
84
89
  });
85
90
  }
@@ -87,17 +92,28 @@ export function checkFileShape(file, text, s) {
87
92
  for (const b of findCommentBlocks(lines)) {
88
93
  if (b.length > s.maxCommentLines && !b.exempt) {
89
94
  issues.push({
90
- file, line: b.start, kind: 'long-comment',
95
+ file, line: b.start, kind: 'long-comment', category: 'style',
91
96
  message: `comment block is ${b.length} lines (max ${s.maxCommentLines})`,
92
97
  });
93
98
  }
94
99
  }
95
100
  return issues;
96
101
  }
102
+ /** Env reads that silently fall back to an empty value; reported as config findings. */
103
+ export function checkConfigFallbacks(file, text) {
104
+ return scanEnvReads(text, file).filter((r) => r.fallback).map((r) => ({
105
+ file, line: r.line, kind: 'config-fallback', category: 'config',
106
+ message: `${r.name} falls back to an empty value when unset; fail loudly instead`,
107
+ }));
108
+ }
97
109
  export function checkCodeShape(targetDir, targetFiles = []) {
98
110
  const settings = loadCodeShapeSettings(targetDir);
99
- const report = { mode: settings.mode, issues: [] };
100
- if (settings.mode === 'off')
111
+ const report = {
112
+ mode: settings.mode, configMode: settings.configFallback, issues: [],
113
+ };
114
+ const style = settings.mode !== 'off';
115
+ const config = settings.configFallback !== 'off';
116
+ if (!style && !config)
101
117
  return report;
102
118
  for (const file of targetFiles) {
103
119
  if (!isCheckable(file))
@@ -106,7 +122,11 @@ export function checkCodeShape(targetDir, targetFiles = []) {
106
122
  try {
107
123
  if (!fs.statSync(full).isFile())
108
124
  continue;
109
- report.issues.push(...checkFileShape(file, fs.readFileSync(full, 'utf8'), settings));
125
+ const text = fs.readFileSync(full, 'utf8');
126
+ if (style)
127
+ report.issues.push(...checkFileShape(file, text, settings));
128
+ if (config)
129
+ report.issues.push(...checkConfigFallbacks(file, text));
110
130
  }
111
131
  catch {
112
132
  // File missing or unreadable: skip.
@@ -114,15 +134,25 @@ export function checkCodeShape(targetDir, targetFiles = []) {
114
134
  }
115
135
  return report;
116
136
  }
117
- /** Short plain-language summary; empty string when there is nothing to report. */
118
- export function formatCodeShapeReport(report) {
119
- if (report.mode === 'off' || report.issues.length === 0)
120
- return '';
121
- const total = report.issues.length;
122
- const head = report.mode === 'block'
123
- ? `Code shape: ${total} issue(s) must be fixed before this task can pass.`
124
- : `Code shape: ${total} style warning(s). Verification is not affected.`;
125
- const shown = report.issues.slice(0, MAX_SHOWN).map((i) => ` ${i.file}:${i.line} ${i.message}`);
126
- const more = total > MAX_SHOWN ? [` ...and ${total - MAX_SHOWN} more`] : [];
137
+ function formatBlock(head, issues) {
138
+ const shown = issues.slice(0, MAX_SHOWN).map((i) => ` ${i.file}:${i.line} ${i.message}`);
139
+ const more = issues.length > MAX_SHOWN ? [` ...and ${issues.length - MAX_SHOWN} more`] : [];
127
140
  return [head, ...shown, ...more].join('\n');
128
141
  }
142
+ /** Short plain-language summary, config findings first; empty string when nothing to report. */
143
+ export function formatCodeShapeReport(report) {
144
+ const config = report.issues.filter((i) => i.category === 'config');
145
+ const style = report.issues.filter((i) => i.category === 'style');
146
+ const blocks = [];
147
+ if (config.length > 0) {
148
+ blocks.push(formatBlock(report.configMode === 'block'
149
+ ? `Config: ${config.length} silent fallback(s) must be fixed before this task can pass.`
150
+ : `Config: ${config.length} silent fallback warning(s). Verification is not affected.`, config));
151
+ }
152
+ if (style.length > 0) {
153
+ blocks.push(formatBlock(report.mode === 'block'
154
+ ? `Code shape: ${style.length} issue(s) must be fixed before this task can pass.`
155
+ : `Code shape: ${style.length} style warning(s). Verification is not affected.`, style));
156
+ }
157
+ return blocks.join('\n');
158
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Scans source text for environment-variable reads (JS/TS, Python, Go) and marks reads that
3
+ * silently fall back to an empty value. Language is chosen by file extension.
4
+ */
5
+ export interface EnvRead {
6
+ name: string;
7
+ line: number;
8
+ fallback: boolean;
9
+ }
10
+ type Lang = 'js' | 'py' | 'go';
11
+ export declare function langForFile(file: string): Lang | null;
12
+ export declare function scanEnvReads(text: string, file: string): EnvRead[];
13
+ /** Variable names read in source files, plus names declared in .env.example (no other .env*). */
14
+ export declare function listProjectEnvNames(root: string): string[];
15
+ export {};
@@ -0,0 +1,126 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ const ALLOW_MARKER = 'nativ-allow-fallback';
5
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git']);
6
+ const MAX_FILE_BYTES = 1024 * 1024;
7
+ const Q = `['"\`]`;
8
+ const EXT_LANG = {
9
+ '.js': 'js', '.jsx': 'js', '.mjs': 'js', '.cjs': 'js',
10
+ '.ts': 'js', '.tsx': 'js', '.mts': 'js', '.cts': 'js',
11
+ '.py': 'py', '.go': 'go',
12
+ };
13
+ const id = String.raw `([A-Za-z_]\w*)`;
14
+ const rx = (src) => new RegExp(src, 'g');
15
+ const PATTERNS = {
16
+ js: [
17
+ rx(String.raw `\b(?:process\.env|import\.meta\.env|Bun\.env)\.` + id),
18
+ rx(String.raw `\b(?:process\.env|import\.meta\.env|Bun\.env)\[\s*${Q}` + id + String.raw `${Q}\s*\]`),
19
+ rx(String.raw `\bDeno\.env\.get\(\s*${Q}` + id + String.raw `${Q}\s*\)`),
20
+ ],
21
+ py: [
22
+ rx(String.raw `\bos\.environ\[\s*${Q}` + id + String.raw `${Q}\s*\]`),
23
+ rx(String.raw `\bos\.(?:environ\.get|getenv)\(\s*${Q}` + id + Q),
24
+ ],
25
+ go: [rx(String.raw `\bos\.(?:Getenv|LookupEnv)\(\s*"` + id + String.raw `"\s*\)`)],
26
+ };
27
+ const JS_FALLBACK = /^\s*(?:\?\?|\|\|)\s*(?:''|""|``|null|undefined)(?![\w$])/;
28
+ const PY_DEFAULT = /^\s*,\s*(?:''|""|None)\s*\)/;
29
+ const PY_OR = /^\s*or\s+(?:''|""|None)(?![\w])/;
30
+ const GO_CMP = /(?:[=!]=\s*""|""\s*[=!]=)/;
31
+ export function langForFile(file) {
32
+ return EXT_LANG[path.extname(file).toLowerCase()] ?? null;
33
+ }
34
+ function isFallback(lang, rest, lines, i, call) {
35
+ if (lang === 'js')
36
+ return JS_FALLBACK.test(rest);
37
+ if (lang === 'py') {
38
+ if (call.includes('environ['))
39
+ return false;
40
+ if (rest.startsWith(')'))
41
+ return PY_OR.test(rest.slice(1));
42
+ return PY_DEFAULT.test(rest);
43
+ }
44
+ if (call.includes('LookupEnv'))
45
+ return false;
46
+ return !lines.slice(i, i + 4).some((l) => GO_CMP.test(l));
47
+ }
48
+ export function scanEnvReads(text, file) {
49
+ const lang = langForFile(file);
50
+ if (!lang)
51
+ return [];
52
+ const lines = text.split(/\r?\n/);
53
+ const out = [];
54
+ lines.forEach((line, i) => {
55
+ const seen = new Set();
56
+ for (const re of PATTERNS[lang]) {
57
+ re.lastIndex = 0;
58
+ for (let m = re.exec(line); m; m = re.exec(line)) {
59
+ if (seen.has(m.index))
60
+ continue;
61
+ seen.add(m.index);
62
+ const rest = line.slice(m.index + m[0].length);
63
+ const allowed = line.includes(ALLOW_MARKER);
64
+ const fallback = !allowed && isFallback(lang, rest, lines, i, m[0]);
65
+ out.push({ name: m[1], line: i + 1, fallback });
66
+ }
67
+ }
68
+ });
69
+ return out.sort((a, b) => a.line - b.line);
70
+ }
71
+ function gitFiles(root) {
72
+ const r = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
73
+ cwd: root, encoding: 'utf8',
74
+ });
75
+ if (r.status !== 0 || typeof r.stdout !== 'string')
76
+ return null;
77
+ return r.stdout.split(/\r?\n/).filter(Boolean);
78
+ }
79
+ function walk(root, rel = '') {
80
+ const out = [];
81
+ for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
82
+ if (SKIP_DIRS.has(e.name))
83
+ continue;
84
+ const next = rel ? `${rel}/${e.name}` : e.name;
85
+ if (e.isDirectory())
86
+ out.push(...walk(root, next));
87
+ else if (e.isFile())
88
+ out.push(next);
89
+ }
90
+ return out;
91
+ }
92
+ function skipped(rel) {
93
+ return rel.split('/').some((p) => SKIP_DIRS.has(p));
94
+ }
95
+ function exampleNames(root) {
96
+ const file = path.join(root, '.env.example');
97
+ if (!fs.existsSync(file))
98
+ return [];
99
+ const names = [];
100
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
101
+ const m = /^\s*(?:export\s+)?([A-Za-z_]\w*)\s*=/.exec(line);
102
+ if (m)
103
+ names.push(m[1]);
104
+ }
105
+ return names;
106
+ }
107
+ /** Variable names read in source files, plus names declared in .env.example (no other .env*). */
108
+ export function listProjectEnvNames(root) {
109
+ const names = new Set(exampleNames(root));
110
+ const files = (gitFiles(root) ?? walk(root)).filter((f) => !skipped(f));
111
+ for (const rel of files) {
112
+ if (!langForFile(rel))
113
+ continue;
114
+ const full = path.join(root, rel);
115
+ try {
116
+ if (fs.statSync(full).size > MAX_FILE_BYTES)
117
+ continue;
118
+ for (const r of scanEnvReads(fs.readFileSync(full, 'utf8'), rel))
119
+ names.add(r.name);
120
+ }
121
+ catch {
122
+ // unreadable or removed file: skip
123
+ }
124
+ }
125
+ return [...names].sort();
126
+ }
@@ -7,9 +7,12 @@ export function isInteractive() {
7
7
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
8
8
  }
9
9
  export function isCiEnvironment() {
10
- return Boolean(process.env.CI === 'true' ||
11
- process.env.GITHUB_ACTIONS === 'true' ||
12
- process.env.GITLAB_CI === 'true' ||
10
+ // Inside a verification sandbox, CI vars are set by nativ and must not open gates
11
+ const sandbox = process.env.NATIV_VERIFY_SANDBOX === '1';
12
+ return Boolean((!sandbox &&
13
+ (process.env.CI === 'true' ||
14
+ process.env.GITHUB_ACTIONS === 'true' ||
15
+ process.env.GITLAB_CI === 'true')) ||
13
16
  process.env.NATIV_CI_OVERRIDE ||
14
17
  process.env.NATIV_HEADLESS_OVERRIDE === '1');
15
18
  }
@@ -6,9 +6,17 @@ export interface ProfileSettings {
6
6
  governorMode: 'auto_adopt' | 'guard';
7
7
  testIntegrity: 'off' | 'warn' | 'block';
8
8
  allowHeadlessBypass: boolean;
9
+ verifyIsolation: 'none' | 'clean';
10
+ verifyIsolationFailure: 'warn' | 'block';
11
+ skippedCheck: 'allow' | 'warn' | 'block';
9
12
  description: string;
10
13
  }
11
14
  export declare const PROFILE_DEFAULTS: Record<NativProfile, ProfileSettings>;
12
15
  export declare function loadProfile(root: string): NativProfile;
13
16
  export declare function resolveActiveProfileSettings(root: string): ProfileSettings;
14
17
  export declare function setProfile(root: string, profile: NativProfile): ProfileSettings;
18
+ export interface VerifySettings {
19
+ verifyEnv: string[];
20
+ verifyLinks: string[];
21
+ }
22
+ export declare function loadVerifySettings(root: string): VerifySettings;
@@ -9,6 +9,9 @@ export const PROFILE_DEFAULTS = {
9
9
  governorMode: 'auto_adopt',
10
10
  testIntegrity: 'off',
11
11
  allowHeadlessBypass: true,
12
+ verifyIsolation: 'none',
13
+ verifyIsolationFailure: 'warn',
14
+ skippedCheck: 'allow',
12
15
  description: 'Hackathon & scratchpad mode: frictionless exploration, auto-adopting contracts, and relaxed scope.',
13
16
  },
14
17
  solo: {
@@ -17,7 +20,11 @@ export const PROFILE_DEFAULTS = {
17
20
  governorMode: 'auto_adopt',
18
21
  testIntegrity: 'warn',
19
22
  allowHeadlessBypass: true,
20
- description: 'Single-developer agility: advisory scope warnings, self-evolving contracts, and streamlined execution.',
23
+ verifyIsolation: 'clean',
24
+ verifyIsolationFailure: 'warn',
25
+ skippedCheck: 'warn',
26
+ description: 'Single-developer agility: advisory scope warnings, self-evolving contracts, '
27
+ + 'and streamlined execution.',
21
28
  },
22
29
  enterprise: {
23
30
  profile: 'enterprise',
@@ -25,7 +32,11 @@ export const PROFILE_DEFAULTS = {
25
32
  governorMode: 'guard',
26
33
  testIntegrity: 'block',
27
34
  allowHeadlessBypass: false,
28
- description: 'Full team rigor: strict contract boundaries, test integrity protection, circuit breakers, and air-gap.',
35
+ verifyIsolation: 'clean',
36
+ verifyIsolationFailure: 'block',
37
+ skippedCheck: 'block',
38
+ description: 'Full team rigor: strict contract boundaries, test integrity protection, '
39
+ + 'circuit breakers, and air-gap.',
29
40
  },
30
41
  };
31
42
  const CONFIG_FILE = path.join('.nativ', 'config.json');
@@ -55,6 +66,15 @@ export function resolveActiveProfileSettings(root) {
55
66
  if (raw.testIntegrity === 'off' || raw.testIntegrity === 'warn' || raw.testIntegrity === 'block') {
56
67
  base.testIntegrity = raw.testIntegrity;
57
68
  }
69
+ if (raw.verifyIsolation === 'none' || raw.verifyIsolation === 'clean') {
70
+ base.verifyIsolation = raw.verifyIsolation;
71
+ }
72
+ if (raw.verifyIsolationFailure === 'warn' || raw.verifyIsolationFailure === 'block') {
73
+ base.verifyIsolationFailure = raw.verifyIsolationFailure;
74
+ }
75
+ if (raw.skippedCheck === 'allow' || raw.skippedCheck === 'warn' || raw.skippedCheck === 'block') {
76
+ base.skippedCheck = raw.skippedCheck;
77
+ }
58
78
  }
59
79
  catch {
60
80
  // Keep base defaults
@@ -68,6 +88,27 @@ export function setProfile(root, profile) {
68
88
  config.enforcement = profileSettings.enforcement;
69
89
  config.governorMode = profileSettings.governorMode;
70
90
  config.testIntegrity = profileSettings.testIntegrity;
91
+ config.verifyIsolation = profileSettings.verifyIsolation;
92
+ config.verifyIsolationFailure = profileSettings.verifyIsolationFailure;
93
+ config.skippedCheck = profileSettings.skippedCheck;
71
94
  });
72
95
  return profileSettings;
73
96
  }
97
+ const DEFAULT_VERIFY_LINKS = ['node_modules', '.venv', 'venv'];
98
+ const BLOCKED_ENV = new Set(['CI', 'GITHUB_ACTIONS', 'GITLAB_CI']);
99
+ function stringList(value, fallback) {
100
+ if (!Array.isArray(value))
101
+ return fallback;
102
+ return value.filter((v) => typeof v === 'string' && v.length > 0);
103
+ }
104
+ export function loadVerifySettings(root) {
105
+ let raw = {};
106
+ try {
107
+ raw = parseJsonLoose(fs.readFileSync(path.join(root, CONFIG_FILE), 'utf8'));
108
+ }
109
+ catch {
110
+ // Missing config keeps defaults
111
+ }
112
+ const verifyEnv = stringList(raw.verifyEnv, []).filter((n) => !BLOCKED_ENV.has(n) && !n.startsWith('NATIV_'));
113
+ return { verifyEnv, verifyLinks: stringList(raw.verifyLinks, DEFAULT_VERIFY_LINKS) };
114
+ }
@@ -35,3 +35,7 @@ export declare function linkWorktreeNodeModules(worktreeDir: string, rootDir: st
35
35
  * project root are never deleted.
36
36
  */
37
37
  export declare function safeUnlinkWorktreeNodeModules(worktreeDir: string): boolean;
38
+ /** Links a folder of the project root into a worktree (junction on Windows). */
39
+ export declare function linkWorktreeDir(worktreeDir: string, rootDir: string, name: string): boolean;
40
+ /** Removes only the link, never the folder it points to. */
41
+ export declare function safeUnlinkWorktreeDir(worktreeDir: string, name: string): boolean;
@@ -193,3 +193,35 @@ export function safeUnlinkWorktreeNodeModules(worktreeDir) {
193
193
  }
194
194
  return false;
195
195
  }
196
+ /** Links a folder of the project root into a worktree (junction on Windows). */
197
+ export function linkWorktreeDir(worktreeDir, rootDir, name) {
198
+ const source = path.join(rootDir, name);
199
+ const target = path.join(worktreeDir, name);
200
+ if (!fs.existsSync(source) || fs.existsSync(target))
201
+ return false;
202
+ try {
203
+ fs.symlinkSync(source, target, process.platform === 'win32' ? 'junction' : 'dir');
204
+ return true;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+ /** Removes only the link, never the folder it points to. */
211
+ export function safeUnlinkWorktreeDir(worktreeDir, name) {
212
+ const target = path.join(worktreeDir, name);
213
+ try {
214
+ if (!fs.lstatSync(target).isSymbolicLink())
215
+ return false;
216
+ try {
217
+ fs.unlinkSync(target);
218
+ }
219
+ catch {
220
+ fs.rmdirSync(target);
221
+ }
222
+ return true;
223
+ }
224
+ catch {
225
+ return false;
226
+ }
227
+ }
@@ -0,0 +1,17 @@
1
+ export declare const BLOCK_START = "# >>> nativ (managed) >>>";
2
+ export declare const BLOCK_END = "# <<< nativ (managed) <<<";
3
+ export type GitVisibility = 'hidden' | 'shared';
4
+ /** Secret and machine-local paths: ignored even when the team shares nativ's files. */
5
+ export declare const SHARED_PATHS: readonly ["/.nativ/*.local.json", "/.nativ/logs/", "/.nativ/*.log", "/.nativ/notify-state.json", "/.worktrees/"];
6
+ /** Root-anchored paths nativ generated in this project, per the git-visibility choice. */
7
+ export declare function listGeneratedPaths(root: string, visibility?: GitVisibility): string[];
8
+ export declare function renderBlock(paths: string[], eol?: string): string;
9
+ /** Inserts or replaces the managed block; text outside it and its line endings stay as they were. */
10
+ export declare function applyBlock(existing: string | null, paths: string[]): string;
11
+ export interface TrackedResult {
12
+ tracked: string[];
13
+ /** Command for the human to run; nativ never runs it. */
14
+ command: string;
15
+ }
16
+ /** Lists generated paths that git already tracks. Non-git folders give an empty list. */
17
+ export declare function trackedNativPaths(root: string, paths: string[]): TrackedResult;