@isomorph.ai/cli 0.10.2 → 0.10.4
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/dist/packages/harbour-cli/src/agent-setup.js +1 -1
- package/dist/packages/harbour-cli/src/check.js +0 -7
- package/dist/packages/harbour-cli/src/deploy.js +3 -4
- package/dist/packages/harbour-cli/src/dev.js +2 -2
- package/dist/packages/harbour-cli/src/guide.js +3 -3
- package/dist/packages/harbour-cli/src/job-fixture.js +4 -21
- package/dist/packages/harbour-cli/src/jobs.js +3 -24
- package/dist/packages/harbour-cli/src/kit.js +4 -4
- package/dist/src/analyzer.js +151 -26
- package/dist/src/gitignore.js +81 -0
- package/package.json +2 -1
|
@@ -123,7 +123,7 @@ Rules live beside this file: \`core.md\` before the first edit; \`integrations.m
|
|
|
123
123
|
| "ship it", "let my team try it" | show the person the three values in \`.isomorph/app.json\` (name, description, audience — "only you" when empty) and correct the file as they say; then \`isomorph deploy --app-root . --json\` (it runs the checks when needed and refuses once naming every blocker and its fix: relay that line; the app works meanwhile). Give them \`result.deployment.protectedUrl\`. \`--session-notes\`: what cost you time in Isomorph, or \`""\`. |
|
|
124
124
|
| "make it live" | only after they have tried the preview: \`isomorph promote --operation <ref> --app-root . --confirm-tested --json\`. Report the production link, or that operator approval is pending. |
|
|
125
125
|
| "stop it" | \`isomorph stop --app-root .\` (data kept); \`isomorph dev --app-root . --reset\` only when they ask to start over. |
|
|
126
|
-
| "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first (
|
|
126
|
+
| "every day at 2pm", "send this automatically" | read \`jobs.md\` beside this skill first (the owner must enable background use of their account); write the job, run it once with \`isomorph jobs run\`, and say its sentence. |
|
|
127
127
|
| "call this API", "a key for it", "run it in the background" | read \`server.md\` beside this skill first; server code goes in \`actions/\` or \`jobs/\`, a key by its name only. |
|
|
128
128
|
|
|
129
129
|
A refusal names its layer, its reason and its fix: change that one thing, then run it again. A wait timeout is not a failure — work is still running: run the command it prints (\`${continueCommand("<ref>")}\`); never start a second deploy while one is running.
|
|
@@ -2,7 +2,6 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CliError } from "./output.js";
|
|
4
4
|
import { DEPENDENT_READ_OPERATIONS, READ_OPERATIONS, kitPaths, readDeclaration, readKitLock, resourceNames, sourceDigest } from "./kit.js";
|
|
5
|
-
import { jobIdentityProblems } from "./jobs.js";
|
|
6
5
|
import { LocalRuntime, allocatePorts, freePort, installDependencies, nodePackageCommand, readDevLock, runningOrigin } from "./local-runtime.js";
|
|
7
6
|
import { CLI_VERSION } from "./version.js";
|
|
8
7
|
/**
|
|
@@ -30,8 +29,6 @@ const DETAIL_CAP = 200;
|
|
|
30
29
|
export const CHECKS_FAILED_HINT = "Full detail is in .isomorph/check-report.json; fix what it reports, then run `isomorph check --app-root .` again.";
|
|
31
30
|
/** The name the gate reports operation coverage under — the pipeline's rule for it. */
|
|
32
31
|
export const FLOW_CHECK = "flow.check-failed";
|
|
33
|
-
/** The CLI's own check over `jobs/`: a company operation a job calls that would run as a person. */
|
|
34
|
-
export const JOBS_CHECK = "jobs.identity";
|
|
35
32
|
/**
|
|
36
33
|
* Runs the kit checks and writes `.isomorph/check-report.json`; a source
|
|
37
34
|
* edit changes sourceDigest and so invalidates the previous report. The app's
|
|
@@ -60,10 +57,6 @@ export async function runChecks(root, options) {
|
|
|
60
57
|
const gate = await runKitGate(root, { run, bundle, output, ...(options.fetch ? { fetch: options.fetch } : {}), ...(options.runtime ? { runtime: options.runtime } : {}), ...(options.pollMs ? { pollMs: options.pollMs } : {}) });
|
|
61
58
|
for (const check of gate.checks)
|
|
62
59
|
record(check);
|
|
63
|
-
// A job runs as the app: a company operation in `jobs/` that needs a person is refused here, not by the deployed schedule.
|
|
64
|
-
const jobs = await jobIdentityProblems(root, await readDeclaration(root));
|
|
65
|
-
if (jobs.length)
|
|
66
|
-
record({ name: JOBS_CHECK, status: "fail", detail: jobs.join("\n") });
|
|
67
60
|
let integrations = "not tested";
|
|
68
61
|
if (options.governance) {
|
|
69
62
|
// Reads that could not run are one check the report carries as not_run with
|
|
@@ -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
|
}
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { createForwarder } from "./forwarder.js";
|
|
6
6
|
import { GovernanceClient, ensureLinkedApp, fileDeclaredRequests, IDENTITY_WORDS, renderGrantGroup } from "./integrations.js";
|
|
7
7
|
import { acquireDevLock, allocatePorts, assertMigrationNames, installDependencies, LocalRuntime, nodePackageCommand, pidAlive, readDevLock, recordDevChildren, releaseDevLock, runCommand, hasServerModules } from "./local-runtime.js";
|
|
8
|
-
import { accessDigest, kitPaths,
|
|
8
|
+
import { accessDigest, kitPaths, readKitLock } from "./kit.js";
|
|
9
9
|
import { startJobFixture } from "./job-fixture.js";
|
|
10
10
|
import { CliError, safeError } from "./output.js";
|
|
11
11
|
/**
|
|
@@ -127,7 +127,7 @@ export async function startDev(root, options) {
|
|
|
127
127
|
// front of the session's gateway (job-fixture.ts: canned AI, company
|
|
128
128
|
// systems refused), exactly what `isomorph jobs run` gives one job.
|
|
129
129
|
if (await hasServerModules(root)) {
|
|
130
|
-
fixture = await startJobFixture({ upstreamPort: ports.gateway, real: false
|
|
130
|
+
fixture = await startJobFixture({ upstreamPort: ports.gateway, real: false });
|
|
131
131
|
if (await runtime.startKitRuntime(session, fixture.url, options.output))
|
|
132
132
|
options.output("Started the app's server side (actions/ and jobs/) on the kit runtime.");
|
|
133
133
|
}
|
|
@@ -91,11 +91,11 @@ const reply = await isomorph.ai.chat({ messages: [{ role: "user", content: \`Sum
|
|
|
91
91
|
The starter calls no AI; add the one call when the person asks. The check's generated ai journey makes a real governed call locally through the development route with your \`isomorph login\`; in the pipeline it is answered by a canned completion and reported as not tested.`,
|
|
92
92
|
jobs: `# Scheduled jobs
|
|
93
93
|
|
|
94
|
-
A job runs as the app
|
|
94
|
+
A job runs as the app. It may use Gmail or user-mode Slack after the app owner explicitly enables background use of their connected account for that app and environment. Without that permission, user-mode calls are refused.
|
|
95
95
|
|
|
96
|
-
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment on the app's server process, which owns the real clock. A job may call \`isomorph.ai.chat\` and
|
|
96
|
+
Scheduled work lives only in \`jobs/<name>.ts\`: export one literal UTC cron as \`schedule\` and one default async handler. Never use \`setInterval\`, an effect or a browser timer as a scheduler; Isomorph runs the same declaration automatically after deployment on the app's server process, which owns the real clock. A job may call \`isomorph.ai.chat\` and approved company operations. A job with no \`schedule\` runs only when enqueued (server.md). Test it immediately: start \`isomorph dev\`, then \`isomorph jobs run <name> --app-root . --scheduled-at <matching-UTC-time> --json\` — local data, canned AI, company systems refused, nothing sent. Use \`--real\` only when the person explicitly asks to test the company action now and the exact action has development approval.
|
|
97
97
|
|
|
98
|
-
A real scheduled send requires the
|
|
98
|
+
A real scheduled send requires the app owner to enable background use of their account, the operation and resource declared in \`.isomorph/integrations.json\`, and a grant for that environment; use one deterministic idempotency key for the business period and destination so a replay does not silently repost.
|
|
99
99
|
|
|
100
100
|
Tell them: "Your scheduled task is ready. I can test the app on your computer now. Company messages will start after the app is online and access is approved."`,
|
|
101
101
|
server: `# Server code: actions, queued runs, outside APIs
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { pipe, readBody, reject } from "./forwarder.js";
|
|
3
|
-
import { isOperationName, OPERATION_IDENTITIES } from "./kit.js";
|
|
4
3
|
/**
|
|
5
4
|
* The origin a job run talks to: a loopback server in front of the session's
|
|
6
5
|
* gateway (an ordinary run) or the dev origin (`--real`).
|
|
@@ -13,24 +12,11 @@ import { isOperationName, OPERATION_IDENTITIES } from "./kit.js";
|
|
|
13
12
|
* front of it proxies those paths to governance. Here, governed AI is answered
|
|
14
13
|
* with the pipeline's canned completion (transformbuild/local_ai_fixture.go),
|
|
15
14
|
* company systems are refused naming `--real` — a canned read would be data
|
|
16
|
-
* the job then acts on
|
|
17
|
-
*
|
|
18
|
-
* person, and `--real` carries the builder's own identity through the
|
|
19
|
-
* forwarder, so a scheduled Gmail send used to pass locally and fail deployed.
|
|
20
|
-
* `isomorph check` refuses the same calls statically (jobs.ts `jobIdentityProblems`).
|
|
15
|
+
* the job then acts on. Real user-mode calls are marked as background runs,
|
|
16
|
+
* so governance requires the same explicit account permission as production.
|
|
21
17
|
*/
|
|
22
18
|
export const AI_FIXTURE_COMPLETION = "AI is not exercised in the build fixture; this is a canned completion.";
|
|
23
19
|
const BODY_LIMIT = 1024 * 1024;
|
|
24
|
-
/** Whether a job's call to `operation` would run as a person: the request's `mode` or the declaration's identity says "user", or the catalog allows no app identity. */
|
|
25
|
-
export function runsAsPerson(operation, declaration, connection, mode) {
|
|
26
|
-
if (!isOperationName(operation))
|
|
27
|
-
return false;
|
|
28
|
-
const identities = OPERATION_IDENTITIES[operation];
|
|
29
|
-
const chosen = mode === "app" || mode === "user" ? mode : declaration.connections[connection]?.operations[operation]?.identity;
|
|
30
|
-
return !identities.includes("app") || chosen === "user";
|
|
31
|
-
}
|
|
32
|
-
/** The refusal a person-only operation gets from a job, in every mode and in `check`. */
|
|
33
|
-
export const personRefusal = (operation) => `${operation} runs as a person; a scheduled job runs as the app and has no person to act for, locally or deployed. Only app-identity operations run in a job.`;
|
|
34
20
|
export async function startJobFixture(options) {
|
|
35
21
|
const server = createServer((request, response) => {
|
|
36
22
|
const path = (request.url ?? "/").split("?")[0];
|
|
@@ -58,15 +44,12 @@ function execute(request, response, raw, options) {
|
|
|
58
44
|
}
|
|
59
45
|
catch { /* the upstream answers a malformed body */ }
|
|
60
46
|
const operation = String(body.operation ?? "");
|
|
61
|
-
if (runsAsPerson(operation, options.declaration, String(body.connection ?? ""), body.mode)) {
|
|
62
|
-
reject(response, 409, "CONFLICT", personRefusal(operation), "IDENTITY_MODE_MISMATCH", { operation, identity: "user" });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
47
|
if (!options.real) {
|
|
66
48
|
reject(response, 409, "CONFLICT", `${operation || "This operation"} is not exercised in a local job run. Run \`isomorph jobs run <name> --real\` to use the company's approved development access; the call then really runs.`, "LOCAL_FIXTURE", { operation });
|
|
67
49
|
return;
|
|
68
50
|
}
|
|
69
|
-
|
|
51
|
+
const forwarded = Buffer.from(JSON.stringify({ ...body, backgroundRun: true }));
|
|
52
|
+
pipe(request, response, options.upstreamPort, {}, forwarded);
|
|
70
53
|
}
|
|
71
54
|
function aiFixture(path) {
|
|
72
55
|
return path === "/_harbour/ai/chat"
|
|
@@ -3,8 +3,8 @@ import { join } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { CliError } from "./output.js";
|
|
5
5
|
import { parseSessionEnv, readDevLock, readLocalSecrets, runCommand } from "./local-runtime.js";
|
|
6
|
-
import { kitPaths
|
|
7
|
-
import {
|
|
6
|
+
import { kitPaths } from "./kit.js";
|
|
7
|
+
import { startJobFixture } from "./job-fixture.js";
|
|
8
8
|
const jobName = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
9
9
|
const scheduleDeclaration = /^export\s+const\s+schedule\s*=\s*["']([^"']+)["']\s*;?\s*$/m;
|
|
10
10
|
const scheduleExport = /\bexport\s+const\s+schedule\b/;
|
|
@@ -48,7 +48,7 @@ export async function runJob(root, name, scheduledAt, run = runCommand, real = f
|
|
|
48
48
|
const runner = `const module = await import(${JSON.stringify(pathToFileURL(job.path).href)}); if (typeof module.default !== "function") throw new Error("job has no default handler"); await module.default({scheduledAt: process.env.ISOMORPH_SCHEDULED_AT});`;
|
|
49
49
|
// The job talks to a loopback origin of its own (job-fixture.ts) in front of
|
|
50
50
|
// the session's gateway, or of the dev origin under `--real`.
|
|
51
|
-
const fixture = await startJobFixture({ upstreamPort: real ? lock.ports.origin : lock.ports.gateway, real
|
|
51
|
+
const fixture = await startJobFixture({ upstreamPort: real ? lock.ports.origin : lock.ports.gateway, real });
|
|
52
52
|
let result;
|
|
53
53
|
try {
|
|
54
54
|
result = await run(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", runner], { cwd: root, env: { ...env, ISOMORPH_GATEWAY_URL: fixture.url, ISOMORPH_SCHEDULED_AT: timestamp } });
|
|
@@ -75,24 +75,3 @@ export function jobFailureReason(stderr) {
|
|
|
75
75
|
return lines.find(line => /^[A-Z]\w*(?:Error|Exception)\b: \S/.test(line))
|
|
76
76
|
?? lines.find(line => /\b(?:error|exception|failed|cannot|unexpected|invalid|denied|refused)\b/i.test(line)) ?? lines[0] ?? "process exited non-zero";
|
|
77
77
|
}
|
|
78
|
-
const executeCall = /isomorph\s*\.\s*integrations\s*\.\s*execute\s*\(\s*["']([^"']+)["']\s*,\s*\{([^)]*)\)/gs;
|
|
79
|
-
/**
|
|
80
|
-
* Every company operation a job calls that would run as a person, one line
|
|
81
|
-
* each: what `isomorph check` refuses, so a scheduled Gmail send is caught
|
|
82
|
-
* before it is deployed and fails there (a job has no person). The same rule
|
|
83
|
-
* answers the call at run time (job-fixture.ts); this is the static half.
|
|
84
|
-
*/
|
|
85
|
-
export async function jobIdentityProblems(root, declaration) {
|
|
86
|
-
const problems = [];
|
|
87
|
-
for (const job of await discoverJobs(root)) {
|
|
88
|
-
const source = await readFile(job.path, "utf8");
|
|
89
|
-
for (const match of source.matchAll(executeCall)) {
|
|
90
|
-
const connection = match[1];
|
|
91
|
-
const operation = /operation\s*:\s*["']([^"']+)["']/.exec(match[2])?.[1] ?? "";
|
|
92
|
-
const mode = /mode\s*:\s*["']([^"']+)["']/.exec(match[2])?.[1];
|
|
93
|
-
if (runsAsPerson(operation, declaration, connection, mode))
|
|
94
|
-
problems.push(`jobs/${job.name}.ts: ${personRefusal(operation)}`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
return problems;
|
|
98
|
-
}
|
|
@@ -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.4",
|
|
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",
|