@remogram/core 0.1.0-beta.5 → 0.1.0-beta.6
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/caps.js +11 -2
- package/index.js +12 -0
- package/merge-blockers.js +10 -3
- package/merge-plan-forge.js +62 -0
- package/merge-plan.js +51 -12
- package/package.json +1 -1
- package/path-allowlist.js +52 -1
package/caps.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const DEFAULT_MAX_BYTES = 8192;
|
|
2
2
|
export const DEFAULT_FIELD_MAX_BYTES = 512;
|
|
3
3
|
export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
|
|
4
|
+
/** Upper bound for undocumented REMOGRAM_FORGE_INGEST_MAX_BYTES debug override. */
|
|
5
|
+
export const MAX_FORGE_INGEST_ENV_BYTES = 65536;
|
|
4
6
|
|
|
5
7
|
/** Conservative check/status page size vs DEFAULT_MAX_BYTES raw ingest cap (pre-parse). */
|
|
6
8
|
export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
|
|
@@ -20,13 +22,20 @@ export function getEffectiveIngestMaxBytes() {
|
|
|
20
22
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
21
23
|
return { bytes: DEFAULT_MAX_BYTES, envOverride: false, invalidEnv: true };
|
|
22
24
|
}
|
|
25
|
+
if (parsed > MAX_FORGE_INGEST_ENV_BYTES) {
|
|
26
|
+
return { bytes: MAX_FORGE_INGEST_ENV_BYTES, envOverride: true, clamped: true };
|
|
27
|
+
}
|
|
23
28
|
return { bytes: parsed, envOverride: true };
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/** Facts for provider capabilities packets (forge ingest policy). */
|
|
27
32
|
export function forgeIngestCapabilityFacts() {
|
|
28
|
-
const { bytes } = getEffectiveIngestMaxBytes();
|
|
29
|
-
return {
|
|
33
|
+
const { bytes, envOverride, clamped } = getEffectiveIngestMaxBytes();
|
|
34
|
+
return {
|
|
35
|
+
forge_ingest_cap_bytes: bytes,
|
|
36
|
+
...(envOverride ? { forge_ingest_env_override: true } : {}),
|
|
37
|
+
...(clamped ? { forge_ingest_cap_clamped: true } : {}),
|
|
38
|
+
};
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
/**
|
package/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
DEFAULT_MAX_BYTES,
|
|
29
29
|
DEFAULT_FIELD_MAX_BYTES,
|
|
30
30
|
FORGE_INGEST_MAX_BYTES_ENV,
|
|
31
|
+
MAX_FORGE_INGEST_ENV_BYTES,
|
|
31
32
|
DEFAULT_CHECK_STATUS_PAGE_SIZE,
|
|
32
33
|
MAX_CHECK_STATUS_PAGES,
|
|
33
34
|
DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
|
|
@@ -135,15 +136,26 @@ export {
|
|
|
135
136
|
} from './write-config.js';
|
|
136
137
|
export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
|
|
137
138
|
export {
|
|
139
|
+
applyForgePathScopeForMergePlan,
|
|
140
|
+
isCrFilesScopeComplete,
|
|
138
141
|
resolveMergePlanPathScope,
|
|
139
142
|
buildMergePlanBody,
|
|
140
143
|
buildMergePlanBodyFromFacts,
|
|
144
|
+
normalizeAllowedPaths,
|
|
141
145
|
} from './merge-plan.js';
|
|
146
|
+
export {
|
|
147
|
+
isMergePlanForgeScopeRethrowError,
|
|
148
|
+
MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES,
|
|
149
|
+
resolveMergePlanOptsWithForgePaths,
|
|
150
|
+
buildMergePlanFromProviderFacts,
|
|
151
|
+
} from './merge-plan-forge.js';
|
|
142
152
|
export {
|
|
143
153
|
matchPathAllowlist,
|
|
144
154
|
isPathAllowed,
|
|
145
155
|
pathsOutsideAllowlist,
|
|
146
156
|
allPathsAllowed,
|
|
157
|
+
normalizeRepoRelativePath,
|
|
158
|
+
normalizeChangedPathList,
|
|
147
159
|
} from './path-allowlist.js';
|
|
148
160
|
export {
|
|
149
161
|
localHeadShaForPr,
|
package/merge-blockers.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allPathsAllowed } from './path-allowlist.js';
|
|
1
|
+
import { allPathsAllowed, normalizeChangedPathList } from './path-allowlist.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Derive merge blockers from already-fetched PR view and checks facts.
|
|
@@ -27,8 +27,15 @@ export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
|
|
|
27
27
|
const changedPaths = pathScope.changed_paths;
|
|
28
28
|
if (changedPaths == null) {
|
|
29
29
|
blockers.push('changed_paths_unavailable');
|
|
30
|
-
} else if (
|
|
31
|
-
|
|
30
|
+
} else if (Array.isArray(changedPaths)) {
|
|
31
|
+
const normalizedPaths = normalizeChangedPathList(changedPaths);
|
|
32
|
+
if (normalizedPaths == null) {
|
|
33
|
+
blockers.push('changed_paths_unavailable');
|
|
34
|
+
} else if (!allPathsAllowed(allowedPaths, normalizedPaths)) {
|
|
35
|
+
blockers.push('path_scope_violation');
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
blockers.push('changed_paths_unavailable');
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ERROR_CODES } from './contracts/errors.js';
|
|
2
|
+
import {
|
|
3
|
+
applyForgePathScopeForMergePlan,
|
|
4
|
+
buildMergePlanBodyFromFacts,
|
|
5
|
+
normalizeAllowedPaths,
|
|
6
|
+
} from './merge-plan.js';
|
|
7
|
+
|
|
8
|
+
/** Errors that must propagate from crFiles during merge-plan path scope — not mapped to changed_paths_unavailable. */
|
|
9
|
+
export const MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES = new Set([
|
|
10
|
+
ERROR_CODES.UNAUTHENTICATED_PROVIDER,
|
|
11
|
+
ERROR_CODES.INVALID_ARGS,
|
|
12
|
+
ERROR_CODES.UNTRUSTED_BASE_URL,
|
|
13
|
+
ERROR_CODES.PROVIDER_UNSUPPORTED,
|
|
14
|
+
ERROR_CODES.CONFIG_INVALID,
|
|
15
|
+
ERROR_CODES.OVERSIZED_RAW_OUTPUT,
|
|
16
|
+
ERROR_CODES.CONFIG_NOT_FOUND,
|
|
17
|
+
ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
|
|
18
|
+
ERROR_CODES.STALE_HEAD,
|
|
19
|
+
ERROR_CODES.MISSING_REF,
|
|
20
|
+
ERROR_CODES.PR_NOT_OPEN,
|
|
21
|
+
ERROR_CODES.REMOTE_INFER_FAILED,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function isMergePlanForgeScopeRethrowError(err) {
|
|
25
|
+
const code = err?.forgeError?.code;
|
|
26
|
+
return typeof code === 'string' && MERGE_PLAN_FORGE_SCOPE_RETHROW_CODES.has(code);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { normalizeAllowedPaths } from './merge-plan.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve merge plan opts with forge cr_files when an allowlist is configured.
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {() => Promise<object>} crFilesFn
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveMergePlanOptsWithForgePaths(opts, crFilesFn) {
|
|
37
|
+
if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
|
|
38
|
+
try {
|
|
39
|
+
const crFilesBody = await crFilesFn();
|
|
40
|
+
return applyForgePathScopeForMergePlan(opts, crFilesBody);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (isMergePlanForgeScopeRethrowError(err)) throw err;
|
|
43
|
+
// Intentional mask bucket: transient api_error → changed_paths_unavailable.
|
|
44
|
+
// write_not_configured / idempotency codes are not emitted by crFiles today.
|
|
45
|
+
return applyForgePathScopeForMergePlan(opts, null);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build merge_plan body from provider deps (shared tri-provider orchestration).
|
|
51
|
+
* @param {object} ctx
|
|
52
|
+
* @param {object} opts
|
|
53
|
+
* @param {{ prView: Function, prChecks: Function, crFiles: Function }} deps
|
|
54
|
+
*/
|
|
55
|
+
export async function buildMergePlanFromProviderFacts(ctx, opts, deps) {
|
|
56
|
+
const view = await deps.prView(ctx, opts);
|
|
57
|
+
const checks = await deps.prChecks(ctx, { number: view.pr_number });
|
|
58
|
+
const mergeOpts = await resolveMergePlanOptsWithForgePaths(opts, () =>
|
|
59
|
+
deps.crFiles(ctx, { number: view.pr_number }),
|
|
60
|
+
);
|
|
61
|
+
return buildMergePlanBodyFromFacts(view, checks, mergeOpts);
|
|
62
|
+
}
|
package/merge-plan.js
CHANGED
|
@@ -1,25 +1,64 @@
|
|
|
1
|
-
import { gitDiffNameOnly } from './git-local.js';
|
|
2
1
|
import { mergeBlockersFromFacts } from './merge-blockers.js';
|
|
2
|
+
import { normalizeChangedPathList } from './path-allowlist.js';
|
|
3
3
|
|
|
4
|
-
function
|
|
4
|
+
function allowlistGlobHasDotDotSegment(glob) {
|
|
5
|
+
if (typeof glob !== 'string') return false;
|
|
6
|
+
const normalized = glob.replace(/\\/g, '/');
|
|
7
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
return normalized.split('/').some((segment) => segment === '..');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeAllowedPaths(allowedPaths) {
|
|
5
14
|
if (!Array.isArray(allowedPaths)) return null;
|
|
6
|
-
const normalized = allowedPaths
|
|
15
|
+
const normalized = allowedPaths
|
|
16
|
+
.filter((entry) => typeof entry === 'string')
|
|
17
|
+
.map((entry) => entry.trim())
|
|
18
|
+
.filter((entry) => entry.length > 0 && !allowlistGlobHasDotDotSegment(entry));
|
|
7
19
|
return normalized.length > 0 ? normalized : null;
|
|
8
20
|
}
|
|
9
21
|
|
|
10
|
-
|
|
22
|
+
/** True when cr_files body is complete enough for merge-plan path scope. */
|
|
23
|
+
export function isCrFilesScopeComplete(crFilesBody) {
|
|
24
|
+
if (!crFilesBody || crFilesBody.paths_truncated) return false;
|
|
25
|
+
const changedPaths = crFilesBody.changed_paths;
|
|
26
|
+
if (!Array.isArray(changedPaths)) return false;
|
|
27
|
+
const pathCount = Number.isFinite(Number(crFilesBody.path_count))
|
|
28
|
+
? Math.floor(Number(crFilesBody.path_count))
|
|
29
|
+
: changedPaths.length;
|
|
30
|
+
if (pathCount > 0 && changedPaths.length === 0) return false;
|
|
31
|
+
if (pathCount > changedPaths.length) return false;
|
|
32
|
+
if (changedPaths.length > pathCount) return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Apply forge cr_files facts to merge plan opts when an allowlist is configured. */
|
|
37
|
+
export function applyForgePathScopeForMergePlan(opts, crFilesBody) {
|
|
38
|
+
if (!normalizeAllowedPaths(opts.allowed_paths)) return opts;
|
|
39
|
+
if (!isCrFilesScopeComplete(crFilesBody)) {
|
|
40
|
+
return { ...opts, changed_paths: null };
|
|
41
|
+
}
|
|
42
|
+
const normalizedPaths = normalizeChangedPathList(crFilesBody.changed_paths);
|
|
43
|
+
if (normalizedPaths == null) {
|
|
44
|
+
return { ...opts, changed_paths: null };
|
|
45
|
+
}
|
|
46
|
+
return { ...opts, changed_paths: normalizedPaths };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveMergePlanPathScope(opts = {}) {
|
|
11
50
|
const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
|
|
12
51
|
if (!allowedPaths) {
|
|
13
52
|
return { allowed_paths: null, changed_paths: null };
|
|
14
53
|
}
|
|
15
54
|
if (Array.isArray(opts.changed_paths)) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
55
|
+
const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
|
|
56
|
+
return {
|
|
57
|
+
allowed_paths: allowedPaths,
|
|
58
|
+
changed_paths: normalizedPaths,
|
|
59
|
+
};
|
|
20
60
|
}
|
|
21
|
-
|
|
22
|
-
return { allowed_paths: allowedPaths, changed_paths: changedPaths };
|
|
61
|
+
return { allowed_paths: allowedPaths, changed_paths: null };
|
|
23
62
|
}
|
|
24
63
|
|
|
25
64
|
export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
@@ -31,7 +70,7 @@ export function buildMergePlanBody(view, checks, pathScope = {}) {
|
|
|
31
70
|
};
|
|
32
71
|
}
|
|
33
72
|
|
|
34
|
-
export function buildMergePlanBodyFromFacts(
|
|
35
|
-
const pathScope = resolveMergePlanPathScope(
|
|
73
|
+
export function buildMergePlanBodyFromFacts(view, checks, opts = {}) {
|
|
74
|
+
const pathScope = resolveMergePlanPathScope(opts);
|
|
36
75
|
return buildMergePlanBody(view, checks, pathScope);
|
|
37
76
|
}
|
package/package.json
CHANGED
package/path-allowlist.js
CHANGED
|
@@ -3,6 +3,56 @@
|
|
|
3
3
|
* Supports `**`, `*`, and literal path segments (e.g. README.md).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Collapse `.` / `..` segments; reject absolute paths and repo-root escape.
|
|
8
|
+
* @param {string} filePath
|
|
9
|
+
* @returns {string|null}
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeRepoRelativePath(filePath) {
|
|
12
|
+
if (typeof filePath !== 'string') return null;
|
|
13
|
+
let path = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
14
|
+
if (path === '') return null;
|
|
15
|
+
if (path.startsWith('/')) return null;
|
|
16
|
+
if (path === '..' || path.startsWith('../')) return null;
|
|
17
|
+
const parts = path.split('/');
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (part === '' || part === '.') continue;
|
|
21
|
+
if (part === '..') {
|
|
22
|
+
if (out.length > 0) out.pop();
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(part);
|
|
26
|
+
}
|
|
27
|
+
return out.join('/');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function changedPathHasDotDotSegment(filePath) {
|
|
31
|
+
if (typeof filePath !== 'string') return false;
|
|
32
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
33
|
+
if (normalized === '..' || normalized.startsWith('../') || normalized.includes('/../')) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return normalized.split('/').some((segment) => segment === '..');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Normalize a forge changed-path list for allowlist scope; null if any path is unnormalizable.
|
|
41
|
+
* @param {unknown} changedPaths
|
|
42
|
+
* @returns {string[]|null}
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeChangedPathList(changedPaths) {
|
|
45
|
+
if (!Array.isArray(changedPaths)) return null;
|
|
46
|
+
const normalized = [];
|
|
47
|
+
for (const filePath of changedPaths) {
|
|
48
|
+
if (changedPathHasDotDotSegment(filePath)) return null;
|
|
49
|
+
const repoPath = normalizeRepoRelativePath(filePath);
|
|
50
|
+
if (repoPath == null) return null;
|
|
51
|
+
normalized.push(repoPath);
|
|
52
|
+
}
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
|
|
6
56
|
function globToRegExp(glob) {
|
|
7
57
|
let pattern = '';
|
|
8
58
|
for (let i = 0; i < glob.length; i += 1) {
|
|
@@ -29,7 +79,8 @@ function globToRegExp(glob) {
|
|
|
29
79
|
*/
|
|
30
80
|
export function matchPathAllowlist(glob, filePath) {
|
|
31
81
|
if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
|
|
32
|
-
const normalized = filePath
|
|
82
|
+
const normalized = normalizeRepoRelativePath(filePath);
|
|
83
|
+
if (normalized == null) return false;
|
|
33
84
|
return globToRegExp(glob).test(normalized);
|
|
34
85
|
}
|
|
35
86
|
|