@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 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 { forge_ingest_cap_bytes: bytes };
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 (!allPathsAllowed(allowedPaths, changedPaths)) {
31
- blockers.push('path_scope_violation');
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 normalizeAllowedPaths(allowedPaths) {
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.filter((entry) => typeof entry === 'string' && entry.length > 0);
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
- export function resolveMergePlanPathScope(ctx, view, opts = {}) {
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
- return { allowed_paths: allowedPaths, changed_paths: opts.changed_paths };
17
- }
18
- if (!view.base_sha || !view.head_sha) {
19
- return { allowed_paths: allowedPaths, changed_paths: null };
55
+ const normalizedPaths = normalizeChangedPathList(opts.changed_paths);
56
+ return {
57
+ allowed_paths: allowedPaths,
58
+ changed_paths: normalizedPaths,
59
+ };
20
60
  }
21
- const changedPaths = gitDiffNameOnly(ctx.cwd, view.base_sha, view.head_sha);
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(ctx, view, checks, opts = {}) {
35
- const pathScope = resolveMergePlanPathScope(ctx, view, opts);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.5",
3
+ "version": "0.1.0-beta.6",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
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.replace(/\\/g, '/').replace(/^\.\//, '');
82
+ const normalized = normalizeRepoRelativePath(filePath);
83
+ if (normalized == null) return false;
33
84
  return globToRegExp(glob).test(normalized);
34
85
  }
35
86