@isomorph.ai/cli 0.10.2 → 0.10.3
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.
|
@@ -227,10 +227,9 @@ async function ensureChecksPassed(root, output, options) {
|
|
|
227
227
|
}
|
|
228
228
|
else if (previous) {
|
|
229
229
|
// Name the paths. Without them the line is true but unactionable: a
|
|
230
|
-
// builder whose agent wrote scratch files into
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// tracks it or not.
|
|
230
|
+
// builder whose agent wrote scratch files into the app root hit this three
|
|
231
|
+
// times before finding the folder. (A gitignored folder is outside the
|
|
232
|
+
// boundary now, so writes there no longer invalidate the checks at all.)
|
|
234
233
|
const moved = describeSourceChange(previous.sourceFiles, tree.entries);
|
|
235
234
|
output(`The app's code has changed since its checks last ran${moved ? ` (${moved})` : ""}; running the checks first.`);
|
|
236
235
|
}
|
|
@@ -270,10 +270,10 @@ export async function sourceDigest(root) {
|
|
|
270
270
|
* What moved between the tree the checks ran on and the tree being deployed.
|
|
271
271
|
*
|
|
272
272
|
* A builder hit this three times in one session: their agent wrote scratch files
|
|
273
|
-
* into a
|
|
274
|
-
*
|
|
275
|
-
* the
|
|
276
|
-
* is the
|
|
273
|
+
* into a folder inside the app root, so every write invalidated the checks. The
|
|
274
|
+
* refusal named the code but not the path, so nothing pointed at the folder.
|
|
275
|
+
* Naming the paths is the fix for files inside the boundary; a gitignored folder
|
|
276
|
+
* is outside it now, so those writes no longer invalidate the checks.
|
|
277
277
|
*/
|
|
278
278
|
export function describeSourceChange(before, after) {
|
|
279
279
|
if (!before)
|
package/dist/src/analyzer.js
CHANGED
|
@@ -2,11 +2,33 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
4
|
import { lstat, readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
5
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
6
|
+
import { parseGitIgnore } from "./gitignore.js";
|
|
6
7
|
import { sha256 } from "./digest.js";
|
|
7
8
|
import { isEnvExamplePath, isKitCommittedPath, isSourceIntakeExcludedPath } from "./secret-paths.js";
|
|
8
9
|
const ANALYZER_VERSION = "0.1.0";
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The deployable boundary is every file under the app root minus the ignored
|
|
12
|
+
* directories and minus what the root `.gitignore` says, up to MAX_FILES.
|
|
13
|
+
*
|
|
14
|
+
* Reaching the limit is an error, never a truncation. The walk is alphabetical,
|
|
15
|
+
* so a silent cut is purely lexicographic: on op harbour-fourier-2af9aaad
|
|
16
|
+
* (2026-09-23) a 600-file cap filled up inside docs/ and the package shipped
|
|
17
|
+
* with no src/ and no package.json, and nothing but an "unknown" said so.
|
|
18
|
+
*
|
|
19
|
+
* 1,500 is what the records can carry today: the CLI's preliminary manifest
|
|
20
|
+
* (path, bytes, sha256 per file) is stored whole inside the SOURCE_UPLOAD
|
|
21
|
+
* DynamoDB item, about 210 bytes per file against the 400 KB item limit.
|
|
22
|
+
* Raising this to the ZIP path's 5,000 needs that manifest moved to S3 first.
|
|
23
|
+
*/
|
|
24
|
+
const MAX_FILES = 1_500;
|
|
25
|
+
/**
|
|
26
|
+
* The env-name scan has its OWN budget, above the packaging cap: a name that
|
|
27
|
+
* never becomes a candidate is never evidenced, never classified and never
|
|
28
|
+
* asked (a 691-file app once lost its Stripe keys to the packaging cut).
|
|
29
|
+
* 5,000 matches the ZIP and git-import limits.
|
|
30
|
+
*/
|
|
31
|
+
const MAX_ENV_SCAN_FILES = 5_000;
|
|
10
32
|
const MAX_FILE_BYTES = 256_000;
|
|
11
33
|
// The pipeline's own receipts and derived artifacts (`.harbour/`, written into
|
|
12
34
|
// the workspace and the published lineage) are control-plane artifacts, not
|
|
@@ -17,7 +39,7 @@ const IGNORED_DIRS = new Set([".git", ".harbour", ".isomorph", ".next", ".nuxt",
|
|
|
17
39
|
const KIT_DIR = ".isomorph";
|
|
18
40
|
const KIT_CONTROL_FILES = [`${KIT_DIR}/integrations.json`, `${KIT_DIR}/kit.lock.json`, `${KIT_DIR}/app.json`];
|
|
19
41
|
const SECRET_FILE_NAMES = new Set([".env", ".env.local", ".env.production", ".env.development", ".npmrc"]);
|
|
20
|
-
const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh"]);
|
|
42
|
+
const TEXT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".html", ".css", ".py", ".rb", ".go", ".rs", ".java", ".cs", ".php", ".md", ".toml", ".yaml", ".yml", ".sh", ".vue", ".svelte", ".astro", ".mts", ".cts"]);
|
|
21
43
|
function normalizeIncludePath(value) {
|
|
22
44
|
if (!value || value === ".")
|
|
23
45
|
return "";
|
|
@@ -28,7 +50,7 @@ function normalizeIncludePath(value) {
|
|
|
28
50
|
throw new Error("ANALYZER_INCLUDE_INVALID: include paths may not contain traversal segments.");
|
|
29
51
|
return parts.join("/");
|
|
30
52
|
}
|
|
31
|
-
async function collectIncludedFiles(root, includePaths, output,
|
|
53
|
+
async function collectIncludedFiles(root, includePaths, output, filter) {
|
|
32
54
|
const normalized = [...new Set(includePaths.map(normalizeIncludePath))];
|
|
33
55
|
for (const include of normalized) {
|
|
34
56
|
if (include.split("/").some(part => IGNORED_DIRS.has(part)) && !isKitCommittedPath(include))
|
|
@@ -53,8 +75,8 @@ async function collectIncludedFiles(root, includePaths, output, unknowns) {
|
|
|
53
75
|
throw new Error(`ANALYZER_INCLUDE_UNSAFE: symlink path component is not allowed: ${include}`);
|
|
54
76
|
}
|
|
55
77
|
if (info.isDirectory())
|
|
56
|
-
await collectFiles(root, absolute, output,
|
|
57
|
-
else
|
|
78
|
+
await collectFiles(root, absolute, output, filter);
|
|
79
|
+
else if (!isGitIgnored(filter, root, absolute, false))
|
|
58
80
|
await collectFile(root, absolute, output);
|
|
59
81
|
}
|
|
60
82
|
}
|
|
@@ -62,30 +84,116 @@ function extension(path) {
|
|
|
62
84
|
const index = path.lastIndexOf(".");
|
|
63
85
|
return index >= 0 ? path.slice(index) : "";
|
|
64
86
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The builder's own `.gitignore` at the app root is the statement of what is
|
|
89
|
+
* not the app: research dumps, exports, caches. It is what a git import would
|
|
90
|
+
* leave out too, so the ZIP path and the git path agree on the boundary.
|
|
91
|
+
* Nested ignore files are not read. A missing file adds no rules.
|
|
92
|
+
*/
|
|
93
|
+
async function loadGitIgnore(root) {
|
|
94
|
+
let content = "";
|
|
95
|
+
try {
|
|
96
|
+
content = await readFile(join(root, ".gitignore"), "utf8");
|
|
97
|
+
}
|
|
98
|
+
catch { /* no .gitignore: nothing extra is ignored */ }
|
|
99
|
+
return parseGitIgnore(content);
|
|
100
|
+
}
|
|
101
|
+
function isGitIgnored(filter, root, absolute, isDirectory) {
|
|
102
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
103
|
+
if (!path)
|
|
104
|
+
return false;
|
|
105
|
+
return filter.ignores(path, isDirectory);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Which directories filled the boundary, for the error a builder reads when
|
|
109
|
+
* the limit is hit: the top-level names with the most collected files.
|
|
110
|
+
*/
|
|
111
|
+
function fileLimitError(output) {
|
|
112
|
+
const counts = new Map();
|
|
113
|
+
for (const file of output) {
|
|
114
|
+
const top = file.path.includes("/") ? `${file.path.slice(0, file.path.indexOf("/"))}/` : file.path;
|
|
115
|
+
counts.set(top, (counts.get(top) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
const largest = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, count]) => `${name} (${count})`).join(", ");
|
|
118
|
+
return new Error(`ANALYZER_FILE_LIMIT_EXCEEDED: the app root holds more than ${MAX_FILES} files. Most are under: ${largest}. Add folders that are not part of the app to .gitignore and run again.`);
|
|
119
|
+
}
|
|
120
|
+
async function collectFiles(root, current, output, filter) {
|
|
68
121
|
const entries = await readdir(current, { withFileTypes: true });
|
|
69
122
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
70
|
-
if (output.length >= MAX_FILES) {
|
|
71
|
-
unknowns.push(`file limit reached at ${MAX_FILES} files`);
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
123
|
// A linked worktree represents .git as a small pointer file rather than a
|
|
75
124
|
// directory. It is Git metadata and must stay outside the app boundary in
|
|
76
125
|
// both checkout layouts.
|
|
77
126
|
if (entry.name === ".git")
|
|
78
127
|
continue;
|
|
128
|
+
const absolute = join(current, entry.name);
|
|
129
|
+
if (entry.isDirectory()) {
|
|
130
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
131
|
+
if (entry.name === KIT_DIR && current === root)
|
|
132
|
+
await collectKitFiles(root, output);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (isGitIgnored(filter, root, absolute, true))
|
|
136
|
+
continue;
|
|
137
|
+
await collectFiles(root, absolute, output, filter);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (!entry.isFile())
|
|
141
|
+
continue;
|
|
142
|
+
if (isGitIgnored(filter, root, absolute, false))
|
|
143
|
+
continue;
|
|
144
|
+
if (output.length >= MAX_FILES)
|
|
145
|
+
throw fileLimitError(output);
|
|
146
|
+
await collectFile(root, absolute, output);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* A second, name-only walk for environment-variable detection. It reads the
|
|
151
|
+
* same file types under the same ignore rules, but carries no packaging
|
|
152
|
+
* meaning and no per-file bookkeeping, so it can afford a much larger budget.
|
|
153
|
+
*/
|
|
154
|
+
async function collectEnvScanFiles(root, current, output, filter) {
|
|
155
|
+
if (output.length >= MAX_ENV_SCAN_FILES)
|
|
156
|
+
return;
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
165
|
+
if (output.length >= MAX_ENV_SCAN_FILES)
|
|
166
|
+
return;
|
|
167
|
+
if (entry.name === ".git")
|
|
168
|
+
continue;
|
|
169
|
+
const absolute = join(current, entry.name);
|
|
79
170
|
if (entry.isDirectory()) {
|
|
80
|
-
if (!IGNORED_DIRS.has(entry.name))
|
|
81
|
-
await
|
|
82
|
-
else if (entry.name === KIT_DIR && current === root)
|
|
83
|
-
await collectKitFiles(root, output);
|
|
171
|
+
if (!IGNORED_DIRS.has(entry.name) && !isGitIgnored(filter, root, absolute, true))
|
|
172
|
+
await collectEnvScanFiles(root, absolute, output, filter);
|
|
84
173
|
continue;
|
|
85
174
|
}
|
|
86
175
|
if (!entry.isFile())
|
|
87
176
|
continue;
|
|
88
|
-
|
|
177
|
+
if (isGitIgnored(filter, root, absolute, false))
|
|
178
|
+
continue;
|
|
179
|
+
const name = entry.name;
|
|
180
|
+
if (SECRET_FILE_NAMES.has(name))
|
|
181
|
+
continue;
|
|
182
|
+
if (!TEXT_EXTENSIONS.has(extension(name)) && !isEnvTemplateFile(name) && !isShellContextFile(name))
|
|
183
|
+
continue;
|
|
184
|
+
let info;
|
|
185
|
+
try {
|
|
186
|
+
info = await lstat(absolute);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
|
|
192
|
+
continue;
|
|
193
|
+
try {
|
|
194
|
+
output.push({ path: relative(root, absolute), basename: name, size: info.size, content: await readFile(absolute, "utf8") });
|
|
195
|
+
}
|
|
196
|
+
catch { /* unreadable file contributes no names */ }
|
|
89
197
|
}
|
|
90
198
|
}
|
|
91
199
|
async function collectKitFiles(root, output) {
|
|
@@ -215,11 +323,11 @@ function detectCapabilities(files, dependencies) {
|
|
|
215
323
|
* capture groups never span a value. */
|
|
216
324
|
const ENV_ACCESS_PATTERNS = [
|
|
217
325
|
// JS/TS dot access and Deno (original rule).
|
|
218
|
-
/(?:process\.env\.|import\.meta\.env\.|Deno\.env\.get\(["'])([A-Z0-9_]{3,})/g,
|
|
326
|
+
/(?:process\.env\.|import\.meta\.env\.|Bun\.env\.|Deno\.env\.get\(\s*["'])([A-Z0-9_]{3,})/g,
|
|
219
327
|
// JS/TS bracket access.
|
|
220
|
-
/(?:process\.env|import\.meta\.env)\[["']([A-Z0-9_]{3,})["']\]/g,
|
|
328
|
+
/(?:process\.env|import\.meta\.env|Bun\.env)\[\s*["']([A-Z0-9_]{3,})["']\s*\]/g,
|
|
221
329
|
// Python: os.environ["X"], os.environ.get("X").
|
|
222
|
-
/\bos\.environ(?:\.get\(\s*|\[)["']([A-Z0-9_]{3,})["']/g,
|
|
330
|
+
/\bos\.environ(?:\.(?:get|setdefault)\(\s*|\[\s*)["']([A-Z0-9_]{3,})["']/g,
|
|
223
331
|
// Go: os.Getenv("X"), os.LookupEnv("X").
|
|
224
332
|
/\bos\.(?:Getenv|LookupEnv)\(\s*"([A-Z0-9_]{3,})"/g,
|
|
225
333
|
// Ruby: ENV["X"], ENV.fetch("X").
|
|
@@ -254,10 +362,17 @@ function pushMatches(content, pattern, names) {
|
|
|
254
362
|
* process.env.STRIPE_SECRET_KEY,` asked a builder for a key on 20 Sept 2026. */
|
|
255
363
|
const COMMENT_LINE = /^\s*(?:\/\/|#|\*|\/\*|<!--)/;
|
|
256
364
|
/** `const { SMTP_HOST, SMTP_PASS } = process.env` reads every destructured name. */
|
|
257
|
-
const ENV_DESTRUCTURE = /\{([^}]*)\}\s*=\s*process\.env\b/g;
|
|
365
|
+
const ENV_DESTRUCTURE = /\{\s*([^{}]*)\}\s*=\s*process\.env\b/g;
|
|
258
366
|
/** `NAME=` at the start of a shell line declares a local variable; `$NAME`
|
|
259
367
|
* later in that file is not an environment read. */
|
|
260
368
|
const SHELL_LOCAL_ASSIGNMENT = /^\s*(?:export\s+)?([A-Z0-9_]{3,})=/gm;
|
|
369
|
+
/**
|
|
370
|
+
* A name DECLARED by a container or compose instruction is not an environment
|
|
371
|
+
* read: `ENV NODE_ENV=production`, `ARG COMMIT_SHA=""`, `- WORKDIR=/app`,
|
|
372
|
+
* `WORKDIR: /app`. Without this, `${WORKDIR}` later in the same Dockerfile
|
|
373
|
+
* made WORKDIR a builder question (29 counted "reads" on one 2026-09-22 app).
|
|
374
|
+
*/
|
|
375
|
+
const MANIFEST_DECLARATION = /^\s*(?:-\s*)?(?:ENV|ARG)\s+([A-Z0-9_]{3,})\s*[=\s]|^\s*(?:-\s*)?([A-Z0-9_]{3,})\s*[:=]/gm;
|
|
261
376
|
function uncommentedLines(content) {
|
|
262
377
|
return content.split(/\r?\n/).filter(line => !COMMENT_LINE.test(line)).join("\n");
|
|
263
378
|
}
|
|
@@ -276,6 +391,8 @@ function detectEnvNames(files) {
|
|
|
276
391
|
}
|
|
277
392
|
if (isShellContextFile(file.basename)) {
|
|
278
393
|
const locals = new Set([...content.matchAll(SHELL_LOCAL_ASSIGNMENT)].map(match => match[1] ?? ""));
|
|
394
|
+
for (const match of content.matchAll(MANIFEST_DECLARATION))
|
|
395
|
+
locals.add(match[1] ?? match[2] ?? "");
|
|
279
396
|
const expanded = [];
|
|
280
397
|
pushMatches(content, SHELL_EXPANSION_PATTERN, expanded);
|
|
281
398
|
names.push(...expanded.filter(name => !locals.has(name)));
|
|
@@ -294,7 +411,9 @@ function detectEnvNames(files) {
|
|
|
294
411
|
}
|
|
295
412
|
if (isEnvTemplateFile(file.basename)) {
|
|
296
413
|
for (const line of (file.content ?? "").split(/\r?\n/)) {
|
|
297
|
-
|
|
414
|
+
// `export NAME=` is the shell-sourced template form; the evidence layer
|
|
415
|
+
// already accepted it, so detection has to as well or the two disagree.
|
|
416
|
+
const envMatch = /^(?:export\s+)?([A-Z0-9_]{3,})=/.exec(line.trim());
|
|
298
417
|
if (envMatch)
|
|
299
418
|
names.push(envMatch[1] ?? "");
|
|
300
419
|
}
|
|
@@ -649,11 +768,17 @@ export async function scanWorkspace(root, options = {}) {
|
|
|
649
768
|
throw new Error("ANALYZER_ROOT_INVALID: selected app root must be a real directory.");
|
|
650
769
|
const unknowns = [];
|
|
651
770
|
const files = [];
|
|
771
|
+
const filter = await loadGitIgnore(root);
|
|
652
772
|
if (options.includePaths?.length)
|
|
653
|
-
await collectIncludedFiles(root, options.includePaths, files,
|
|
773
|
+
await collectIncludedFiles(root, options.includePaths, files, filter);
|
|
654
774
|
else
|
|
655
|
-
await collectFiles(root, root, files,
|
|
775
|
+
await collectFiles(root, root, files, filter);
|
|
656
776
|
const scopedFiles = files.sort((a, b) => a.path.localeCompare(b.path));
|
|
777
|
+
// Env names come from their own wider walk, except when the caller scoped
|
|
778
|
+
// the scan to explicit include paths — there the scope IS the answer.
|
|
779
|
+
const envScanFiles = [];
|
|
780
|
+
if (!options.includePaths?.length)
|
|
781
|
+
await collectEnvScanFiles(root, root, envScanFiles, filter);
|
|
657
782
|
if (options.includePaths?.length && scopedFiles.length === 0)
|
|
658
783
|
throw new Error("ANALYZER_INCLUDE_EMPTY: the declared include paths contain no eligible files.");
|
|
659
784
|
const fingerprintInput = scopedFiles.map(file => `${file.path}:${file.size}:${file.content ? createHash("sha256").update(file.content).digest("hex") : "unread"}`).join("\n");
|
|
@@ -681,7 +806,7 @@ export async function scanWorkspace(root, options = {}) {
|
|
|
681
806
|
capabilities: detectCapabilities(scopedFiles, dependencies),
|
|
682
807
|
packageManagers,
|
|
683
808
|
dependencyNames: dependencies,
|
|
684
|
-
environmentVariableNames: detectEnvNames(scopedFiles),
|
|
809
|
+
environmentVariableNames: detectEnvNames(envScanFiles.length ? envScanFiles : scopedFiles),
|
|
685
810
|
testCommands: detectTestCommands(scripts),
|
|
686
811
|
analysis: {
|
|
687
812
|
analyzerVersion: ANALYZER_VERSION,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function parseGitIgnore(content) {
|
|
2
|
+
const rules = [];
|
|
3
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
4
|
+
let line = raw.replace(/\s+$/, "");
|
|
5
|
+
if (!line || line.startsWith("#"))
|
|
6
|
+
continue;
|
|
7
|
+
let negated = false;
|
|
8
|
+
if (line.startsWith("!")) {
|
|
9
|
+
negated = true;
|
|
10
|
+
line = line.slice(1);
|
|
11
|
+
}
|
|
12
|
+
else if (line.startsWith("\\!") || line.startsWith("\\#"))
|
|
13
|
+
line = line.slice(1);
|
|
14
|
+
let directoryOnly = false;
|
|
15
|
+
if (line.endsWith("/")) {
|
|
16
|
+
directoryOnly = true;
|
|
17
|
+
line = line.slice(0, -1);
|
|
18
|
+
}
|
|
19
|
+
if (!line)
|
|
20
|
+
continue;
|
|
21
|
+
let anchored = line.startsWith("/");
|
|
22
|
+
if (anchored)
|
|
23
|
+
line = line.slice(1);
|
|
24
|
+
if (line.includes("/"))
|
|
25
|
+
anchored = true;
|
|
26
|
+
const regex = new RegExp(`^${anchored ? "" : "(?:.*/)?"}${globToRegex(line)}(?:/.*)?$`);
|
|
27
|
+
rules.push({ regex, negated, directoryOnly });
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
ignores(path, isDirectory) {
|
|
31
|
+
const normalized = path.replace(/^\/+|\/+$/g, "");
|
|
32
|
+
if (!normalized)
|
|
33
|
+
return false;
|
|
34
|
+
let ignored = false;
|
|
35
|
+
for (const rule of rules) {
|
|
36
|
+
if (rule.directoryOnly && !isDirectory && !parentMatches(rule.regex, normalized))
|
|
37
|
+
continue;
|
|
38
|
+
if (rule.regex.test(normalized))
|
|
39
|
+
ignored = !rule.negated;
|
|
40
|
+
}
|
|
41
|
+
return ignored;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** A directory-only pattern still ignores a file beneath a matching directory. */
|
|
46
|
+
function parentMatches(regex, path) {
|
|
47
|
+
const parts = path.split("/");
|
|
48
|
+
for (let depth = 1; depth < parts.length; depth += 1)
|
|
49
|
+
if (regex.test(parts.slice(0, depth).join("/")))
|
|
50
|
+
return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
function globToRegex(glob) {
|
|
54
|
+
let out = "";
|
|
55
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
56
|
+
const char = glob[index];
|
|
57
|
+
if (char === "*") {
|
|
58
|
+
if (glob[index + 1] === "*") {
|
|
59
|
+
index += 1;
|
|
60
|
+
if (glob[index + 1] === "/") {
|
|
61
|
+
index += 1;
|
|
62
|
+
out += "(?:.*/)?";
|
|
63
|
+
}
|
|
64
|
+
else
|
|
65
|
+
out += ".*";
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
out += "[^/]*";
|
|
69
|
+
}
|
|
70
|
+
else if (char === "?")
|
|
71
|
+
out += "[^/]";
|
|
72
|
+
else if (char === "\\" && index + 1 < glob.length) {
|
|
73
|
+
index += 1;
|
|
74
|
+
out += escape(glob[index]);
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
out += escape(char);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function escape(char) { return /[.*+?^${}()|[\]\\/]/.test(char) ? `\\${char}` : char; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@isomorph.ai/cli",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Isomorph development kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"dist/src/analyzer.js",
|
|
19
19
|
"dist/src/contracts.js",
|
|
20
20
|
"dist/src/digest.js",
|
|
21
|
+
"dist/src/gitignore.js",
|
|
21
22
|
"dist/src/secret-paths.js",
|
|
22
23
|
"dist/src/source-digest.js",
|
|
23
24
|
"dist/src/source-intake.js",
|