@njeybe/nativ 2.8.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +62 -6
- package/bin/cli.js +1 -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/milestone.d.ts +10 -0
- package/dist/commands/milestone.js +84 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +1 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +39 -8
- package/dist/commands/task/task-add.js +62 -9
- 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 +48 -4
- package/dist/commands/task/task-query.js +4 -2
- package/dist/commands/task/task-show.d.ts +9 -0
- package/dist/commands/task/task-show.js +74 -0
- package/dist/commands/update.d.ts +1 -0
- package/dist/commands/update.js +1 -1
- package/dist/commands/validate.js +24 -0
- 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/milestone-archive.d.ts +40 -0
- package/dist/core/milestone-archive.js +299 -0
- 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/pm-playbook.d.ts +1 -1
- package/dist/core/setup/pm-playbook.js +7 -1
- package/dist/core/setup/session-context.d.ts +9 -1
- package/dist/core/setup/session-context.js +24 -2
- 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 +95 -41
- 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 +22 -0
- package/dist/index.js +47 -7
- package/dist/mcp/server.js +27 -2
- package/dist/runner/agent-supervisor.js +4 -1
- package/dist/scanner/types.d.ts +18 -0
- package/dist/scanner/types.js +11 -0
- package/dist/server/routes/task-routes.d.ts +2 -0
- package/dist/server/routes/task-routes.js +13 -5
- package/dist/server/studio-ui/shell/model.js +16 -9
- 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/agents/worker.md +3 -3
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/skills/nativ/SKILL.md +13 -9
- package/templates/AGENTS.md +13 -9
- package/templates/claude-agents/architect.md +14 -7
- package/templates/claude-agents/verifier.md +16 -14
- package/templates/claude-agents/worker.md +3 -3
- 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
|
|
30
|
+
/** Short plain-language summary, config findings first; empty string when nothing to report. */
|
|
26
31
|
export declare function formatCodeShapeReport(report: CodeShapeReport): string;
|
package/dist/core/code-shape.js
CHANGED
|
@@ -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
|
-
|
|
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 = {
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
package/dist/core/human-gate.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type MasterPlan } from '../scanner/types.js';
|
|
2
|
+
export declare const DEFAULT_SUITE_TIMEOUT_MS = 600000;
|
|
3
|
+
/** An expected "no": the message is written for the person running the command. */
|
|
4
|
+
export declare class ArchiveRefusal extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** Seams so tests can make the read-back, the removal or the plan save fail. */
|
|
7
|
+
export interface ArchiveDeps {
|
|
8
|
+
readBack?: (file: string) => string;
|
|
9
|
+
save?: (planPath: string, plan: MasterPlan) => boolean;
|
|
10
|
+
remove?: (file: string) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface ArchiveRequest {
|
|
13
|
+
ids: string[];
|
|
14
|
+
summaries: Record<string, string>;
|
|
15
|
+
check?: string;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ArchiveResult {
|
|
19
|
+
archived: Array<{
|
|
20
|
+
id: string;
|
|
21
|
+
file: string;
|
|
22
|
+
taskCount: number;
|
|
23
|
+
}>;
|
|
24
|
+
activeMilestoneId: string;
|
|
25
|
+
commit: string | null;
|
|
26
|
+
warnings: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface RestoreResult {
|
|
29
|
+
id: string;
|
|
30
|
+
taskCount: number;
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
/** Ids of tasks moved out to archive files; they count as existing, completed tasks. */
|
|
34
|
+
export declare function archivedTaskIds(plan: MasterPlan): string[];
|
|
35
|
+
/** The plan as dependency checks should see it: archived tasks appear as completed. */
|
|
36
|
+
export declare function withArchivedAsDone(plan: MasterPlan): MasterPlan;
|
|
37
|
+
/** Refuses without side effects, runs the suite once outside the lock, then re-checks and commits. */
|
|
38
|
+
export declare function archiveMilestones(targetDir: string, request: ArchiveRequest, deps?: ArchiveDeps): Promise<ArchiveResult>;
|
|
39
|
+
/** Puts the tasks back; the archive file goes only after the plan is safely saved. */
|
|
40
|
+
export declare function restoreMilestone(targetDir: string, id: string, deps?: ArchiveDeps): Promise<RestoreResult>;
|