@remogram/core 0.1.0-beta.3 → 0.1.0-beta.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/auth-classes.js CHANGED
@@ -17,6 +17,7 @@ export const API_PROVIDER_COMMAND_AUTH = {
17
17
  pr_checks: AUTH_CLASS.TOKEN_REQUIRED,
18
18
  merge_plan: AUTH_CLASS.TOKEN_REQUIRED,
19
19
  sync_plan: AUTH_CLASS.GIT_ONLY,
20
+ cr_open: AUTH_CLASS.TOKEN_REQUIRED,
20
21
  };
21
22
 
22
23
  export function commandCapability(name, { implemented = true } = {}) {
@@ -27,10 +28,11 @@ export function commandCapability(name, { implemented = true } = {}) {
27
28
  return { name, implemented, auth_class };
28
29
  }
29
30
 
30
- export function apiProviderCommands() {
31
- return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) =>
32
- commandCapability(name, { implemented: true }),
33
- );
31
+ export function apiProviderCommands({ writeCommandsImplemented = false } = {}) {
32
+ return Object.keys(API_PROVIDER_COMMAND_AUTH).map((name) => {
33
+ const implemented = name === 'cr_open' ? writeCommandsImplemented : true;
34
+ return commandCapability(name, { implemented });
35
+ });
34
36
  }
35
37
 
36
38
  export function stubProviderCommands() {
package/caps.js CHANGED
@@ -6,6 +6,11 @@ export const FORGE_INGEST_MAX_BYTES_ENV = 'REMOGRAM_FORGE_INGEST_MAX_BYTES';
6
6
  export const DEFAULT_CHECK_STATUS_PAGE_SIZE = 25;
7
7
  export const MAX_CHECK_STATUS_PAGES = 50;
8
8
 
9
+ /** Gitea open-pull list page size for idempotency scan and inventory list bounds. */
10
+ export const DEFAULT_OPEN_PULL_LIST_PAGE_SIZE = 100;
11
+ /** Max pages scanned before cr open idempotency fails closed (decoupled from check-status pagination). */
12
+ export const MAX_OPEN_PULL_IDEMPOTENCY_PAGES = 50;
13
+
9
14
  export function getEffectiveIngestMaxBytes() {
10
15
  const raw = process.env[FORGE_INGEST_MAX_BYTES_ENV];
11
16
  if (raw == null || raw === '') {
@@ -48,6 +53,46 @@ export function checkPaginationCapabilityFacts({ strategy, pageSizeParam, source
48
53
  };
49
54
  }
50
55
 
56
+ /** Structured idempotency scan facts for provider capabilities (cr open). */
57
+ export function idempotencyScanCapabilityFacts() {
58
+ return {
59
+ idempotency_scan: {
60
+ max_pages: MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
61
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
62
+ ingest_backoff: 'halve_until_fit',
63
+ },
64
+ };
65
+ }
66
+
67
+ /** Structured open-pull list pagination facts for provider capabilities (cr inventory). */
68
+ export function openPullListCapabilityFacts({
69
+ totalCountSource = null,
70
+ totalCountHeader = null,
71
+ sliceSortNotes = null,
72
+ } = {}) {
73
+ const compliantMaxItems = DEFAULT_OPEN_PULL_LIST_PAGE_SIZE * MAX_CHECK_STATUS_PAGES;
74
+ return {
75
+ open_pull_list: {
76
+ max_pages: MAX_CHECK_STATUS_PAGES,
77
+ page_size: DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
78
+ ingest_backoff: 'halve_until_fit',
79
+ compliant_max_items: compliantMaxItems,
80
+ truncation_packet_field: 'list_truncated',
81
+ incomplete_error_code: 'inventory_list_incomplete',
82
+ default_slice_sort: 'number_asc',
83
+ supported_slice_sorts: [
84
+ 'number_asc',
85
+ 'number_desc',
86
+ 'recent_update',
87
+ 'recent_created',
88
+ ],
89
+ ...(totalCountSource ? { total_count_source: totalCountSource } : {}),
90
+ ...(totalCountHeader ? { total_count_header: totalCountHeader } : {}),
91
+ ...(sliceSortNotes ? { slice_sort_notes: sliceSortNotes } : {}),
92
+ },
93
+ };
94
+ }
95
+
51
96
  export function capText(text, maxBytes = DEFAULT_MAX_BYTES) {
52
97
  if (!text) return { text: '', truncated: false, bytes: 0 };
53
98
  const buf = Buffer.from(text, 'utf8');
@@ -1,5 +1,6 @@
1
1
  import { ERROR_CODES } from './contracts/errors.js';
2
2
  import { DEFAULT_CHECK_STATUS_PAGE_SIZE, MAX_CHECK_STATUS_PAGES } from './caps.js';
3
+ import { resolvePaginatedEntryCount } from './open-pull-list.js';
3
4
 
4
5
  function isOversizedIngestError(err) {
5
6
  return err?.forgeError?.code === ERROR_CODES.OVERSIZED_RAW_OUTPUT;
@@ -65,6 +66,21 @@ export async function fetchPageWithIngestBackoff(fetchPage, page, initialLimit)
65
66
  return { items, usedLimit };
66
67
  }
67
68
 
69
+ /**
70
+ * When a full page lands on maxPages, probe one item on the next page to distinguish
71
+ * end-of-list from truncation.
72
+ * @template T
73
+ * @param {(opts: { page: number, limit: number }) => Promise<unknown[]>} fetchPage
74
+ * @param {number} page
75
+ * @param {number} maxPages
76
+ * @returns {Promise<boolean>} true when list is truncated (more items exist)
77
+ */
78
+ async function probeNextPageHasItems(fetchPage, page, maxPages) {
79
+ if (page > maxPages) return true;
80
+ const { items: probeItems } = await fetchPageWithIngestBackoff(fetchPage, page + 1, 1);
81
+ return probeItems.length > 0;
82
+ }
83
+
68
84
  /**
69
85
  * Offset/limit check-status pagination with ingest-cap backoff.
70
86
  * @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize?: number, maxPages?: number }} opts
@@ -99,8 +115,8 @@ export async function paginateCheckStatusPages({
99
115
  /**
100
116
  * Offset/limit open-list pagination with ingest-cap backoff and optional list cap.
101
117
  * listLimit bounds request size per page; callers slice returned items when enforcing a hard cap.
102
- * @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean }} opts
103
- * @returns {Promise<{ items: unknown[], list_truncated: boolean }>}
118
+ * @param {{ fetchPage: (opts: { page: number, limit: number }) => Promise<unknown[]>, pageSize: number, listLimit?: number | null, maxPages?: number, maxPagesTruncatesWithLimit?: boolean, retainMax?: number | null, trustedEntryCount?: number | null, seededFirstPage?: { items: unknown[], usedLimit: number } | null, startPage?: number, suppressFinalPageProbe?: boolean }} opts
119
+ * @returns {Promise<{ items: unknown[], list_truncated: boolean, walked_count: number, entry_count?: number }>}
104
120
  */
105
121
  export async function paginateOffsetListPages({
106
122
  fetchPage,
@@ -108,31 +124,92 @@ export async function paginateOffsetListPages({
108
124
  listLimit = null,
109
125
  maxPages = MAX_CHECK_STATUS_PAGES,
110
126
  maxPagesTruncatesWithLimit = false,
127
+ retainMax = null,
128
+ trustedEntryCount = null,
129
+ seededFirstPage = null,
130
+ startPage = 1,
131
+ suppressFinalPageProbe = false,
111
132
  }) {
112
133
  const all = [];
134
+ let entryCount = 0;
113
135
  let listTruncated = false;
114
136
  let activeLimit = pageSize;
115
- for (let page = 1; page <= maxPages; page += 1) {
116
- const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
117
- if (listLimit != null && remaining === 0) break;
118
- const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
119
- const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
120
- activeLimit = usedLimit;
121
- all.push(...items);
122
- if (items.length < usedLimit) break;
123
- if (listLimit != null) {
124
- if (all.length >= listLimit) {
125
- listTruncated = items.length >= usedLimit;
126
- break;
137
+
138
+ async function afterPage(page, items, usedLimit) {
139
+ entryCount += items.length;
140
+ if (retainMax != null) {
141
+ const space = Math.max(retainMax - all.length, 0);
142
+ if (space > 0) all.push(...items.slice(0, space));
143
+ } else {
144
+ all.push(...items);
145
+ }
146
+ if (items.length < usedLimit) {
147
+ if (
148
+ trustedEntryCount != null &&
149
+ trustedEntryCount > entryCount &&
150
+ page === 1 &&
151
+ page < maxPages
152
+ ) {
153
+ return false;
127
154
  }
155
+ return true;
156
+ }
157
+ if (listLimit != null && all.length >= listLimit) {
158
+ listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
159
+ return true;
160
+ }
161
+ if (listLimit != null) {
128
162
  if (maxPagesTruncatesWithLimit && page === maxPages) {
129
163
  listTruncated = true;
130
- break;
164
+ return true;
131
165
  }
132
166
  } else if (page === maxPages) {
133
- listTruncated = true;
167
+ if (suppressFinalPageProbe) {
168
+ listTruncated = items.length >= usedLimit;
169
+ } else {
170
+ listTruncated = await probeNextPageHasItems(fetchPage, page, maxPages);
171
+ }
172
+ return true;
173
+ }
174
+ return false;
175
+ }
176
+
177
+ let page = startPage;
178
+ if (seededFirstPage && startPage === 1) {
179
+ const { items, usedLimit } = seededFirstPage;
180
+ activeLimit = usedLimit;
181
+ if (await afterPage(1, items, usedLimit)) {
182
+ return {
183
+ items: all,
184
+ list_truncated: listTruncated,
185
+ walked_count: entryCount,
186
+ ...(retainMax != null || trustedEntryCount != null
187
+ ? { entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount) }
188
+ : {}),
189
+ };
190
+ }
191
+ page = 2;
192
+ }
193
+
194
+ for (; page <= maxPages; page += 1) {
195
+ const remaining = listLimit != null ? Math.max(listLimit - all.length, 0) : activeLimit;
196
+ if (listLimit != null && remaining === 0) break;
197
+ const requestLimit = listLimit != null ? Math.min(activeLimit, remaining) : activeLimit;
198
+ const { items, usedLimit } = await fetchPageWithIngestBackoff(fetchPage, page, requestLimit);
199
+ activeLimit = usedLimit;
200
+ if (await afterPage(page, items, usedLimit)) {
134
201
  break;
135
202
  }
136
203
  }
137
- return { items: all, list_truncated: listTruncated };
204
+
205
+ return {
206
+ items: all,
207
+ list_truncated: listTruncated,
208
+ walked_count: entryCount,
209
+ ...(retainMax != null || trustedEntryCount != null
210
+ ? {
211
+ entry_count: resolvePaginatedEntryCount(trustedEntryCount, entryCount),
212
+ }
213
+ : {}),
214
+ };
138
215
  }
package/config-schema.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { writeCommandSchema } from './write-config.js';
2
3
 
3
4
  const providerSchema = z.enum([
4
5
  'gitea-api',
@@ -23,6 +24,7 @@ export const configSchema = z
23
24
  owner: repoSegmentSchema,
24
25
  repo: repoSegmentSchema,
25
26
  baseUrl: z.string().url().optional(),
27
+ write_commands: z.array(writeCommandSchema).optional(),
26
28
  })
27
29
  .strict();
28
30
 
@@ -1,4 +1,5 @@
1
1
  import { sanitizeField } from '../caps.js';
2
+ import { normalizeForgeErrorFields } from './forge-error-fields.js';
2
3
 
3
4
  export const SCHEMA_VERSION = 1;
4
5
 
@@ -12,6 +13,7 @@ export const PACKET_TYPES = {
12
13
  PROVIDER_CAPABILITIES: 'provider_capabilities',
13
14
  PROVIDER_DOCTOR: 'provider_doctor',
14
15
  FORGE_ERROR: 'forge_error',
16
+ CHANGE_REQUEST_OPENED: 'change_request_opened',
15
17
  };
16
18
 
17
19
  export const FORBIDDEN_PACKET_KEYS = new Set([
@@ -29,7 +31,7 @@ function assertNoForbiddenKeys(value) {
29
31
  }
30
32
  for (const [key, nested] of Object.entries(value)) {
31
33
  if (FORBIDDEN_PACKET_KEYS.has(key)) {
32
- throw new Error(`Forbidden Topogram concept in remogram output: ${key}`);
34
+ throw new Error(`Forbidden workflow/planning-tool key in remogram output: ${key}`);
33
35
  }
34
36
  assertNoForbiddenKeys(nested);
35
37
  }
@@ -53,13 +55,21 @@ export function forgePacket(type, context, body = {}, error = null) {
53
55
  packet.error_code = error.code;
54
56
  packet.error_message = sanitizeField(error.message);
55
57
  if (error.status != null) packet.error_status = error.status;
58
+ if (error.fields != null && typeof error.fields === 'object') {
59
+ assertNoForbiddenKeys(error.fields);
60
+ const trustedFields = normalizeForgeErrorFields(error.code, error.fields);
61
+ if (trustedFields != null) {
62
+ Object.assign(packet, trustedFields);
63
+ }
64
+ }
56
65
  }
57
66
 
58
67
  return packet;
59
68
  }
60
69
 
61
70
  export function forgeErrorPacket(context, error, type = PACKET_TYPES.FORGE_ERROR) {
62
- return forgePacket(type, context, {}, error);
71
+ const body = error?.fields != null && typeof error.fields === 'object' ? error.fields : {};
72
+ return forgePacket(type, context, body, error);
63
73
  }
64
74
 
65
75
  export function unknownForgeContext() {
@@ -13,8 +13,16 @@ export const ERROR_CODES = {
13
13
  API_ERROR: 'api_error',
14
14
  PR_NOT_OPEN: 'pr_not_open',
15
15
  REMOTE_INFER_FAILED: 'remote_infer_failed',
16
+ WRITE_NOT_CONFIGURED: 'write_not_configured',
17
+ IDEMPOTENCY_SCAN_INCOMPLETE: 'idempotency_scan_incomplete',
18
+ INVENTORY_LIST_INCOMPLETE: 'inventory_list_incomplete',
16
19
  };
17
20
 
18
- export function forgeError(code, message, status = null) {
19
- return { code, message, ...(status != null ? { status } : {}) };
21
+ export function forgeError(code, message, status = null, fields = null) {
22
+ return {
23
+ code,
24
+ message,
25
+ ...(status != null ? { status } : {}),
26
+ ...(fields != null && typeof fields === 'object' ? { fields } : {}),
27
+ };
20
28
  }
@@ -0,0 +1,97 @@
1
+ import { ERROR_CODES } from './errors.js';
2
+
3
+ /** Top-level packet keys forge error fields must never override. */
4
+ const FORBIDDEN_ERROR_FIELD_KEYS = new Set([
5
+ 'type',
6
+ 'schema_version',
7
+ 'provider_id',
8
+ 'remote_name',
9
+ 'repo_id',
10
+ 'observed_at',
11
+ 'ok',
12
+ 'error_code',
13
+ 'error_message',
14
+ 'error_status',
15
+ ]);
16
+
17
+ /** Trusted body fields allowed per forge error code. */
18
+ export const FORGE_ERROR_FIELD_ALLOWLIST = Object.freeze({
19
+ [ERROR_CODES.IDEMPOTENCY_SCAN_INCOMPLETE]: ['idempotency_scan'],
20
+ [ERROR_CODES.INVENTORY_LIST_INCOMPLETE]: ['inventory_list'],
21
+ });
22
+
23
+ function assertPositiveInteger(name, value) {
24
+ if (!Number.isInteger(value) || value <= 0) {
25
+ throw new Error(`Invalid forge error field ${name}: must be a positive integer`);
26
+ }
27
+ }
28
+
29
+ function validateIdempotencyScan(scan) {
30
+ if (scan == null || typeof scan !== 'object' || Array.isArray(scan)) {
31
+ throw new Error('Invalid forge error field idempotency_scan: must be an object');
32
+ }
33
+ const keys = Object.keys(scan).sort();
34
+ const expected = ['max_pages', 'page_size', 'pages'];
35
+ if (keys.length !== expected.length || !expected.every((k) => keys.includes(k))) {
36
+ throw new Error('Invalid forge error field idempotency_scan: unexpected keys');
37
+ }
38
+ assertPositiveInteger('idempotency_scan.pages', scan.pages);
39
+ assertPositiveInteger('idempotency_scan.max_pages', scan.max_pages);
40
+ assertPositiveInteger('idempotency_scan.page_size', scan.page_size);
41
+ return { idempotency_scan: scan };
42
+ }
43
+
44
+ function validateInventoryList(list) {
45
+ if (list == null || typeof list !== 'object' || Array.isArray(list)) {
46
+ throw new Error('Invalid forge error field inventory_list: must be an object');
47
+ }
48
+ const keys = Object.keys(list).sort();
49
+ if (keys.length !== 1 || keys[0] !== 'entry_count') {
50
+ throw new Error('Invalid forge error field inventory_list: unexpected keys');
51
+ }
52
+ assertPositiveInteger('inventory_list.entry_count', list.entry_count);
53
+ return { inventory_list: list };
54
+ }
55
+
56
+ /**
57
+ * Validate and normalize trusted forge_error body fields before packet merge.
58
+ * @param {string} code
59
+ * @param {Record<string, unknown> | null | undefined} fields
60
+ * @returns {Record<string, unknown> | null}
61
+ */
62
+ export function normalizeForgeErrorFields(code, fields) {
63
+ if (fields == null) return null;
64
+ if (typeof fields !== 'object' || Array.isArray(fields)) {
65
+ throw new Error('Invalid forge error fields: must be an object');
66
+ }
67
+
68
+ const keys = Object.keys(fields);
69
+ if (keys.length === 0) return null;
70
+
71
+ for (const key of keys) {
72
+ if (FORBIDDEN_ERROR_FIELD_KEYS.has(key)) {
73
+ throw new Error(`Forge error fields cannot override packet field ${key}`);
74
+ }
75
+ }
76
+
77
+ const allowlist = FORGE_ERROR_FIELD_ALLOWLIST[code];
78
+ if (!allowlist) {
79
+ throw new Error(`Forge error code ${code} does not allow trusted fields`);
80
+ }
81
+
82
+ for (const key of keys) {
83
+ if (!allowlist.includes(key)) {
84
+ throw new Error(`Forge error field ${key} is not allowed for code ${code}`);
85
+ }
86
+ }
87
+
88
+ if (fields.idempotency_scan != null) {
89
+ return validateIdempotencyScan(fields.idempotency_scan);
90
+ }
91
+
92
+ if (fields.inventory_list != null) {
93
+ return validateInventoryList(fields.inventory_list);
94
+ }
95
+
96
+ return fields;
97
+ }
@@ -1,10 +1,7 @@
1
1
  /**
2
- * Remogram fact requirements for Topogram branch-workcycle observer snapshots.
3
- * Observer proto today captures remogram repo status only; semantic-diff consumers
2
+ * Remogram fact requirements for observer and semantic-diff consumer snapshots.
3
+ * Observer proto today captures remogram repo status only; downstream consumers
4
4
  * may compose additional read-only fact packets listed here.
5
- *
6
- * @see ../topogram/tools/branch-workcycle/observer-snapshot.sh
7
- * @see topo/sdlc/acceptance_criteria/semantic_diff_fact_inventory.tg ac_semantic_diff_observer_facts
8
5
  */
9
6
 
10
7
  import { FACT_INVENTORY_PACKET_TYPES, V1_READ_PLAN_COMMANDS } from './semantic-diff-facts.js';
@@ -22,6 +22,9 @@ export const V1_READ_PLAN_COMMANDS = Object.freeze([
22
22
  'doctor',
23
23
  ]);
24
24
 
25
+ /** v1 write surface (Gitea cr open only in first slice). */
26
+ export const V1_WRITE_COMMANDS = Object.freeze(['cr open']);
27
+
25
28
  /**
26
29
  * Planned fact-inventory packet types (not emitted until wave 2+ commands ship).
27
30
  * All use schema_version 1 envelope discipline via forgePacket.
@@ -55,11 +58,14 @@ export const TRUSTED_NORMALIZED_BODY_FIELDS = Object.freeze({
55
58
  truncated: true,
56
59
  list_truncated: true,
57
60
  checks_truncated: true,
61
+ slice_sort: true,
58
62
  entry_count: true,
59
63
  mergeability_confidence: true,
60
64
  write_support: true,
61
65
  diverged: true,
62
66
  auth_present: true,
67
+ reused_existing: true,
68
+ idempotency_scan: true,
63
69
  });
64
70
 
65
71
  /**
@@ -74,6 +80,7 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
74
80
  merge_plan: ['blockers[].message', 'blockers[].context'],
75
81
  sync_plan: ['remote', 'local_sha', 'remote_sha', 'blockers[].message'],
76
82
  ref_inventory: ['refs[].name', 'refs[].sha', 'default_ref'],
83
+ change_request_opened: ['url', 'title', 'head', 'base'],
77
84
  cr_inventory_slice: [
78
85
  'entries[].url',
79
86
  'entries[].title',
@@ -88,7 +95,7 @@ export const FORGE_SOURCED_STRING_LEAVES = Object.freeze({
88
95
  ],
89
96
  });
90
97
 
91
- /** Keys that must never appear in remogram output (Topogram SDLC/workflow concepts). */
98
+ /** Keys that must never appear in remogram output (external planning/SDLC workflow concepts). */
92
99
  export { FORBIDDEN_PACKET_KEYS };
93
100
 
94
101
  /**
@@ -108,6 +115,8 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
108
115
  /** true when list cap applied (entry_count > limit), not missing entries */
109
116
  truncated: 'boolean',
110
117
  list_truncated: 'boolean',
118
+ /** normalized slice sort preset applied to open-list resolution */
119
+ slice_sort: 'string',
111
120
  entries_skipped:
112
121
  'array<{ pr_number: number, error_code: pr_not_open | api_error | oversized_raw_output | ... }> optional',
113
122
  slice_ref: 'string optional',
@@ -116,7 +125,7 @@ export const FACT_INVENTORY_BODY_SHAPES = Object.freeze({
116
125
 
117
126
  /**
118
127
  * Build a fact-inventory packet body through the standard envelope gate.
119
- * Throws if body contains forbidden Topogram workflow keys.
128
+ * Throws if body contains forbidden workflow/planning-tool keys.
120
129
  */
121
130
  export function forgeFactInventoryPacket(type, context, body = {}, error = null) {
122
131
  if (!Object.values(FACT_INVENTORY_PACKET_TYPES).includes(type)) {
package/cr-inventory.js CHANGED
@@ -1,12 +1,18 @@
1
1
  import { sanitizeField } from './caps.js';
2
2
  import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
3
  import { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
4
+ import {
5
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
6
+ normalizeCrInventorySort,
7
+ } from './open-pull-list.js';
4
8
  import { staleHeadDetails } from './pr-head-reconcile.js';
5
9
 
6
10
  export const DEFAULT_CR_INVENTORY_LIMIT = 50;
11
+ /** Default bound when `--limit` is omitted (keeps forge ingest under cap on large repos). */
12
+ export const DEFAULT_CR_INVENTORY_SAFE_LIMIT = 3;
7
13
 
8
14
  export function normalizeCrInventoryLimit(value) {
9
- if (value == null || value === '') return DEFAULT_CR_INVENTORY_LIMIT;
15
+ if (value == null || value === '') return DEFAULT_CR_INVENTORY_SAFE_LIMIT;
10
16
  const n = Number(value);
11
17
  if (!Number.isInteger(n) || n <= 0) {
12
18
  throw Object.assign(new Error('Invalid cr inventory limit'), {
@@ -16,12 +22,11 @@ export function normalizeCrInventoryLimit(value) {
16
22
  return n;
17
23
  }
18
24
 
19
- async function resolveOpenPullList(provider, ctx, limit) {
20
- const listOpts = { limit };
25
+ async function resolveOpenPullList(provider, ctx, entryLimit, sliceSort) {
21
26
  if (typeof provider.listOpenPullsWithMeta === 'function') {
22
- return provider.listOpenPullsWithMeta(ctx, listOpts);
27
+ return provider.listOpenPullsWithMeta(ctx, { retain_max: entryLimit, sort: sliceSort });
23
28
  }
24
- const numbers = await provider.listOpenPulls(ctx, listOpts);
29
+ const numbers = await provider.listOpenPulls(ctx, {});
25
30
  return { numbers, list_truncated: false };
26
31
  }
27
32
 
@@ -71,16 +76,17 @@ export function buildCrInventoryEntry(ctx, view, checks) {
71
76
  * Aggregate open change requests into a semantic-diff-oriented inventory slice.
72
77
  * @param {object} ctx forge context
73
78
  * @param {object} provider must expose listOpenPulls, prView, prChecks
74
- * @param {{ slice_ref?: string, limit?: number }} [opts]
79
+ * @param {{ slice_ref?: string, limit?: number, sort?: string }} [opts]
75
80
  */
76
81
  export async function crInventory(ctx, provider, opts = {}) {
77
82
  const limit = normalizeCrInventoryLimit(opts.limit);
78
- const { numbers, list_truncated: listTruncated } = await resolveOpenPullList(
79
- provider,
80
- ctx,
81
- limit,
82
- );
83
- const entryCount = numbers.length;
83
+ const sliceSort = normalizeCrInventorySort(opts.sort);
84
+ const {
85
+ numbers,
86
+ list_truncated: listTruncated,
87
+ entry_count: providerEntryCount,
88
+ } = await resolveOpenPullList(provider, ctx, limit, sliceSort);
89
+ const entryCount = providerEntryCount ?? numbers.length;
84
90
  const selected = numbers.slice(0, limit);
85
91
  const entries = [];
86
92
  const entries_skipped = [];
@@ -106,6 +112,7 @@ export async function crInventory(ctx, provider, opts = {}) {
106
112
  entry_count: entryCount,
107
113
  truncated: entryCount > selected.length,
108
114
  list_truncated: listTruncated,
115
+ slice_sort: sliceSort ?? DEFAULT_CR_INVENTORY_SLICE_SORT,
109
116
  ...(opts.slice_ref ? { slice_ref: sanitizeField(opts.slice_ref) } : {}),
110
117
  };
111
118
  }
package/cr-open.js ADDED
@@ -0,0 +1,33 @@
1
+ import { sanitizeField, sanitizeUrl } from './caps.js';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+
4
+ /** Normalize Gitea pull create response into change_request_opened body fields. */
5
+ export function buildChangeRequestOpenedBody(
6
+ pull,
7
+ { head, base, title },
8
+ { reusedExisting = false } = {},
9
+ ) {
10
+ const prNumber = Number(pull?.number);
11
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
12
+ throw Object.assign(new Error('Provider returned invalid pull number'), {
13
+ forgeError: forgeError(
14
+ ERROR_CODES.UNPARSEABLE_PROVIDER_OUTPUT,
15
+ 'Provider returned invalid pull number',
16
+ ),
17
+ });
18
+ }
19
+ const resolvedTitle = reusedExisting
20
+ ? sanitizeField(pull?.title ?? title)
21
+ : sanitizeField(title ?? pull?.title);
22
+ const body = {
23
+ pr_number: prNumber,
24
+ url: sanitizeUrl(pull.html_url ?? pull.url),
25
+ head: sanitizeField(head),
26
+ base: sanitizeField(base),
27
+ title: resolvedTitle,
28
+ };
29
+ if (reusedExisting) {
30
+ body.reused_existing = true;
31
+ }
32
+ return body;
33
+ }
package/git-local.js CHANGED
@@ -43,3 +43,15 @@ export function gitAheadBehind(cwd, base, head) {
43
43
  return { ahead_by: null, behind_by: null };
44
44
  }
45
45
  }
46
+
47
+ export function gitDiffNameOnly(cwd, base, head) {
48
+ assertGitRef(base, 'base');
49
+ assertGitRef(head, 'head');
50
+ try {
51
+ const out = gitExec(cwd, ['diff', '--name-only', base, head]);
52
+ if (!out) return [];
53
+ return out.split('\n').filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
package/index.js CHANGED
@@ -16,6 +16,10 @@ export {
16
16
  allObserverEligibleCommands,
17
17
  } from './contracts/observer-fact-inventory.js';
18
18
  export { ERROR_CODES, forgeError } from './contracts/errors.js';
19
+ export {
20
+ FORGE_ERROR_FIELD_ALLOWLIST,
21
+ normalizeForgeErrorFields,
22
+ } from './contracts/forge-error-fields.js';
19
23
  export {
20
24
  capText,
21
25
  sanitizeField,
@@ -26,9 +30,13 @@ export {
26
30
  FORGE_INGEST_MAX_BYTES_ENV,
27
31
  DEFAULT_CHECK_STATUS_PAGE_SIZE,
28
32
  MAX_CHECK_STATUS_PAGES,
33
+ DEFAULT_OPEN_PULL_LIST_PAGE_SIZE,
34
+ MAX_OPEN_PULL_IDEMPOTENCY_PAGES,
29
35
  getEffectiveIngestMaxBytes,
30
36
  forgeIngestCapabilityFacts,
31
37
  checkPaginationCapabilityFacts,
38
+ idempotencyScanCapabilityFacts,
39
+ openPullListCapabilityFacts,
32
40
  } from './caps.js';
33
41
  export {
34
42
  paginateCheckStatusPages,
@@ -39,10 +47,51 @@ export {
39
47
  withLimitParam,
40
48
  } from './check-pagination.js';
41
49
  export { assertGitRef, assertGitRemote } from './git-args.js';
42
- export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot } from './git-local.js';
50
+ export { gitRevParse, gitCurrentBranch, gitAheadBehind, gitRepoRoot, gitDiffNameOnly } from './git-local.js';
43
51
  export { buildRefInventoryBody, refsInventory } from './ref-inventory.js';
44
- export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
52
+ export { buildCrInventoryEntry, buildHeadReconcile, crInventory, DEFAULT_CR_INVENTORY_LIMIT, DEFAULT_CR_INVENTORY_SAFE_LIMIT, normalizeCrInventoryLimit } from './cr-inventory.js';
53
+ export {
54
+ CR_INVENTORY_SLICE_SORTS,
55
+ DEFAULT_CR_INVENTORY_SLICE_SORT,
56
+ normalizeCrInventorySort,
57
+ parseTotalCountHeader,
58
+ isCrInventoryFastPathEligible,
59
+ forgeOrderAuthoritative,
60
+ validateFastPathPageLength,
61
+ isNumberSortFastPathEligible,
62
+ resolvePaginatedEntryCount,
63
+ resolveListTruncatedWithTrustedTotal,
64
+ isRecentCreatedFastPathEligible,
65
+ giteaRecentCreatedTailPage,
66
+ isNumberSortFullCollectRequired,
67
+ prepareGiteaOpenPullPageItems,
68
+ orderOpenPullNumbers,
69
+ buildOpenPullListMeta,
70
+ giteaOpenPullSortQuery,
71
+ gitlabOpenPullSortQuery,
72
+ githubOpenPullSortQuery,
73
+ appendSortQuery,
74
+ } from './open-pull-list.js';
75
+ export { buildChangeRequestOpenedBody } from './cr-open.js';
76
+ export {
77
+ WRITE_COMMAND_IDS,
78
+ CONFIGURED_WRITE_COMMANDS,
79
+ writeCommandSchema,
80
+ assertWriteCommandConfigured,
81
+ isWriteCommandConfigured,
82
+ } from './write-config.js';
45
83
  export { mergeBlockersFromFacts, isOpenPrState } from './merge-blockers.js';
84
+ export {
85
+ resolveMergePlanPathScope,
86
+ buildMergePlanBody,
87
+ buildMergePlanBodyFromFacts,
88
+ } from './merge-plan.js';
89
+ export {
90
+ matchPathAllowlist,
91
+ isPathAllowed,
92
+ pathsOutsideAllowlist,
93
+ allPathsAllowed,
94
+ } from './path-allowlist.js';
46
95
  export {
47
96
  localHeadShaForPr,
48
97
  staleHeadDetails,
package/merge-blockers.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { allPathsAllowed } from './path-allowlist.js';
2
+
1
3
  /**
2
4
  * Derive merge blockers from already-fetched PR view and checks facts.
3
5
  * Shared by merge plan and cr inventory aggregation.
@@ -6,7 +8,12 @@ export function isOpenPrState(state) {
6
8
  return String(state ?? '').toLowerCase() === 'open';
7
9
  }
8
10
 
9
- export function mergeBlockersFromFacts(view, checks) {
11
+ /**
12
+ * @param {object} view
13
+ * @param {object} checks
14
+ * @param {{ allowed_paths?: string[], changed_paths?: string[] | null }} [pathScope]
15
+ */
16
+ export function mergeBlockersFromFacts(view, checks, pathScope = {}) {
10
17
  const blockers = [];
11
18
  if (view.mergeability === 'conflicted') blockers.push('merge_conflict');
12
19
  if (!isOpenPrState(view.state)) blockers.push('pr_not_open');
@@ -14,5 +21,16 @@ export function mergeBlockersFromFacts(view, checks) {
14
21
  if (checks.check_conclusion === 'failure') blockers.push('checks_failed');
15
22
  if (checks.check_conclusion === 'missing') blockers.push('checks_missing');
16
23
  if (checks.check_conclusion === 'pending') blockers.push('checks_pending');
24
+
25
+ const allowedPaths = pathScope.allowed_paths;
26
+ if (Array.isArray(allowedPaths) && allowedPaths.length > 0) {
27
+ const changedPaths = pathScope.changed_paths;
28
+ if (changedPaths == null) {
29
+ blockers.push('changed_paths_unavailable');
30
+ } else if (!allPathsAllowed(allowedPaths, changedPaths)) {
31
+ blockers.push('path_scope_violation');
32
+ }
33
+ }
34
+
17
35
  return blockers;
18
36
  }
package/merge-plan.js ADDED
@@ -0,0 +1,34 @@
1
+ import { gitDiffNameOnly } from './git-local.js';
2
+ import { mergeBlockersFromFacts } from './merge-blockers.js';
3
+
4
+ function normalizeAllowedPaths(allowedPaths) {
5
+ if (!Array.isArray(allowedPaths)) return null;
6
+ const normalized = allowedPaths.filter((entry) => typeof entry === 'string' && entry.length > 0);
7
+ return normalized.length > 0 ? normalized : null;
8
+ }
9
+
10
+ export function resolveMergePlanPathScope(ctx, view, opts = {}) {
11
+ const allowedPaths = normalizeAllowedPaths(opts.allowed_paths);
12
+ if (!allowedPaths) {
13
+ return { allowed_paths: null, changed_paths: null };
14
+ }
15
+ if (!view.base_sha || !view.head_sha) {
16
+ return { allowed_paths: allowedPaths, changed_paths: null };
17
+ }
18
+ const changedPaths = gitDiffNameOnly(ctx.cwd, view.base_sha, view.head_sha);
19
+ return { allowed_paths: allowedPaths, changed_paths: changedPaths };
20
+ }
21
+
22
+ export function buildMergePlanBody(view, checks, pathScope = {}) {
23
+ return {
24
+ pr_number: view.pr_number,
25
+ mergeability: view.mergeability,
26
+ checks_conclusion: checks.check_conclusion,
27
+ blockers: mergeBlockersFromFacts(view, checks, pathScope),
28
+ };
29
+ }
30
+
31
+ export function buildMergePlanBodyFromFacts(ctx, view, checks, opts = {}) {
32
+ const pathScope = resolveMergePlanPathScope(ctx, view, opts);
33
+ return buildMergePlanBody(view, checks, pathScope);
34
+ }
@@ -0,0 +1,256 @@
1
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
2
+
3
+ /** Normalized slice sort keys exposed on CLI/MCP and success packets. */
4
+ export const CR_INVENTORY_SLICE_SORTS = Object.freeze([
5
+ 'number_asc',
6
+ 'number_desc',
7
+ 'recent_update',
8
+ 'recent_created',
9
+ ]);
10
+
11
+ export const DEFAULT_CR_INVENTORY_SLICE_SORT = 'number_asc';
12
+
13
+ /**
14
+ * @param {unknown} value
15
+ * @returns {typeof CR_INVENTORY_SLICE_SORTS[number]}
16
+ */
17
+ export function normalizeCrInventorySort(value) {
18
+ if (value == null || value === '') return DEFAULT_CR_INVENTORY_SLICE_SORT;
19
+ const sort = String(value).trim().toLowerCase();
20
+ if (!CR_INVENTORY_SLICE_SORTS.includes(sort)) {
21
+ throw Object.assign(new Error('Invalid cr inventory sort'), {
22
+ forgeError: forgeError(
23
+ ERROR_CODES.INVALID_ARGS,
24
+ `--sort must be one of: ${CR_INVENTORY_SLICE_SORTS.join(', ')}`,
25
+ ),
26
+ });
27
+ }
28
+ return sort;
29
+ }
30
+
31
+ /**
32
+ * Parse forge total-count response header as a positive integer within sanity bounds.
33
+ * @param {Headers | Record<string, string> | null | undefined} headers
34
+ * @param {string} headerName
35
+ * @param {{ maxTrusted: number }} opts
36
+ * @returns {number | null}
37
+ */
38
+ export function parseTotalCountHeader(headers, headerName, { maxTrusted }) {
39
+ if (headers == null || maxTrusted <= 0) return null;
40
+ const read =
41
+ typeof headers.get === 'function'
42
+ ? (name) => headers.get(name)
43
+ : (name) => headers[name] ?? headers[String(name).toLowerCase()];
44
+ const raw = read(headerName) ?? read(String(headerName).toLowerCase());
45
+ if (raw == null || raw === '') return null;
46
+ const n = Number.parseInt(String(raw).trim(), 10);
47
+ if (!Number.isFinite(n) || n <= 0 || n > maxTrusted) return null;
48
+ return n;
49
+ }
50
+
51
+ /**
52
+ * Fast path applies to cr inventory retain_max slices, not idempotency scans.
53
+ * @param {{ retain_max?: number | null, limit?: number | null }} opts
54
+ */
55
+ export function isCrInventoryFastPathEligible(opts = {}) {
56
+ return (
57
+ opts.retain_max != null &&
58
+ Number.isInteger(Number(opts.retain_max)) &&
59
+ Number(opts.retain_max) > 0
60
+ );
61
+ }
62
+
63
+ /**
64
+ * When forge order is authoritative, skip client-side number reordering.
65
+ * @param {string} sliceSort
66
+ */
67
+ export function forgeOrderAuthoritative(sliceSort) {
68
+ return sliceSort === 'recent_update' || sliceSort === 'recent_created';
69
+ }
70
+
71
+ /**
72
+ * Fast path requires the first page to contain the expected item count.
73
+ * @param {number} totalCount
74
+ * @param {number} requestLimit
75
+ * @param {number} bodyLength
76
+ */
77
+ export function validateFastPathPageLength(totalCount, requestLimit, bodyLength) {
78
+ const expected = Math.min(totalCount, requestLimit);
79
+ return bodyLength === expected;
80
+ }
81
+
82
+ /**
83
+ * Number sorts need full local reorder; skip fast path when total exceeds retain_max.
84
+ * @param {number} totalCount
85
+ * @param {number} retainMax
86
+ * @param {string} sliceSort
87
+ */
88
+ export function isNumberSortFastPathEligible(totalCount, retainMax, sliceSort) {
89
+ if (sliceSort !== 'number_asc' && sliceSort !== 'number_desc') return true;
90
+ return totalCount <= retainMax;
91
+ }
92
+
93
+ /**
94
+ * Prefer forge header/search total over summed pagination lengths on fallback.
95
+ * @param {number | null | undefined} trustedTotal
96
+ * @param {number} summedCount
97
+ */
98
+ export function resolvePaginatedEntryCount(trustedTotal, summedCount) {
99
+ if (trustedTotal != null && Number.isInteger(trustedTotal) && trustedTotal > 0) {
100
+ return trustedTotal;
101
+ }
102
+ return summedCount;
103
+ }
104
+
105
+ /**
106
+ * When forge total is trusted but pagination walked fewer items, mark truncated.
107
+ * @param {{ listTruncated: boolean, trustedTotalCount?: number | null, walkedCount: number, fullCollect?: boolean }} opts
108
+ */
109
+ export function resolveListTruncatedWithTrustedTotal({
110
+ listTruncated,
111
+ trustedTotalCount,
112
+ walkedCount,
113
+ fullCollect = false,
114
+ }) {
115
+ if (
116
+ trustedTotalCount != null &&
117
+ Number.isInteger(trustedTotalCount) &&
118
+ trustedTotalCount > 0 &&
119
+ walkedCount < trustedTotalCount
120
+ ) {
121
+ return true;
122
+ }
123
+ return listTruncated;
124
+ }
125
+
126
+ /**
127
+ * Gitea recent_created uses oldest sort; fast path only when total fits retain_max.
128
+ * @param {number} totalCount
129
+ * @param {number} retainMax
130
+ * @param {string} sliceSort
131
+ * @param {string} providerId
132
+ */
133
+ export function isRecentCreatedFastPathEligible(totalCount, retainMax, sliceSort, providerId) {
134
+ if (sliceSort !== 'recent_created' || providerId !== 'gitea-api') return true;
135
+ return totalCount <= retainMax;
136
+ }
137
+
138
+ /**
139
+ * Last page index for Gitea sort=oldest when fetching globally newest-created slice.
140
+ * @param {number} totalCount
141
+ * @param {number} pageSize
142
+ */
143
+ export function giteaRecentCreatedTailPage(totalCount, pageSize) {
144
+ if (!Number.isInteger(totalCount) || totalCount <= 0) return 1;
145
+ if (!Number.isInteger(pageSize) || pageSize <= 0) return 1;
146
+ return Math.max(1, Math.ceil(totalCount / pageSize));
147
+ }
148
+
149
+ /**
150
+ * Number sorts need full local reorder when total exceeds retain_max.
151
+ * @param {number} totalCount
152
+ * @param {number} retainMax
153
+ * @param {string} sliceSort
154
+ */
155
+ export function isNumberSortFullCollectRequired(totalCount, retainMax, sliceSort) {
156
+ if (forgeOrderAuthoritative(sliceSort)) return false;
157
+ return !isNumberSortFastPathEligible(totalCount, retainMax, sliceSort);
158
+ }
159
+
160
+ /**
161
+ * Gitea sort=oldest returns oldest-first; recent_created needs newest-first.
162
+ * @param {unknown[]} items
163
+ * @param {string} sliceSort
164
+ */
165
+ export function prepareGiteaOpenPullPageItems(items, sliceSort) {
166
+ if (sliceSort !== 'recent_created' || !Array.isArray(items)) return items;
167
+ return items.slice().reverse();
168
+ }
169
+
170
+ /**
171
+ * @param {unknown[]} items
172
+ * @param {(item: unknown) => number | null | undefined} getNumber
173
+ * @param {string} sliceSort
174
+ * @returns {number[]}
175
+ */
176
+ export function orderOpenPullNumbers(items, getNumber, sliceSort) {
177
+ const numbers = items
178
+ .map(getNumber)
179
+ .filter((number) => Number.isInteger(number));
180
+ if (forgeOrderAuthoritative(sliceSort)) return numbers;
181
+ numbers.sort((a, b) => (sliceSort === 'number_desc' ? b - a : a - b));
182
+ return numbers;
183
+ }
184
+
185
+ /**
186
+ * @param {{ totalCount?: number | null, numbers: number[], listTruncated: boolean, sliceSort?: string }} meta
187
+ */
188
+ export function buildOpenPullListMeta({ totalCount, numbers, listTruncated, sliceSort }) {
189
+ return {
190
+ numbers,
191
+ list_truncated: listTruncated,
192
+ ...(totalCount != null ? { entry_count: totalCount } : {}),
193
+ ...(sliceSort ? { slice_sort: sliceSort } : {}),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Gitea query params for normalized slice sort.
199
+ * @param {string} sliceSort
200
+ */
201
+ export function giteaOpenPullSortQuery(sliceSort) {
202
+ switch (sliceSort) {
203
+ case 'recent_update':
204
+ return { sort: 'recentupdate' };
205
+ case 'recent_created':
206
+ return { sort: 'oldest' };
207
+ default:
208
+ return {};
209
+ }
210
+ }
211
+
212
+ /**
213
+ * GitLab query params for normalized slice sort.
214
+ * @param {string} sliceSort
215
+ */
216
+ export function gitlabOpenPullSortQuery(sliceSort) {
217
+ switch (sliceSort) {
218
+ case 'recent_update':
219
+ return { order_by: 'updated_at', sort: 'desc' };
220
+ case 'recent_created':
221
+ return { order_by: 'created_at', sort: 'desc' };
222
+ case 'number_desc':
223
+ return { order_by: 'created_at', sort: 'desc' };
224
+ default:
225
+ return { order_by: 'created_at', sort: 'asc' };
226
+ }
227
+ }
228
+
229
+ /**
230
+ * GitHub query params for normalized slice sort on list pulls.
231
+ * @param {string} sliceSort
232
+ */
233
+ export function githubOpenPullSortQuery(sliceSort) {
234
+ switch (sliceSort) {
235
+ case 'recent_update':
236
+ return { sort: 'updated', direction: 'desc' };
237
+ case 'recent_created':
238
+ return { sort: 'created', direction: 'desc' };
239
+ case 'number_desc':
240
+ return { sort: 'created', direction: 'desc' };
241
+ default:
242
+ return { sort: 'created', direction: 'asc' };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Append URLSearchParams for forge sort query entries.
248
+ * @param {string} path
249
+ * @param {Record<string, string>} query
250
+ */
251
+ export function appendSortQuery(path, query) {
252
+ if (!query || Object.keys(query).length === 0) return path;
253
+ const sep = path.includes('?') ? '&' : '?';
254
+ const params = new URLSearchParams(query);
255
+ return `${path}${sep}${params.toString()}`;
256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remogram/core",
3
- "version": "0.1.0-beta.3",
3
+ "version": "0.1.0-beta.4",
4
4
  "description": "Remogram forge envelope, config, caps, and HTTP utilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Glob allowlist matching for autonomous Observer auto-merge scope checks.
3
+ * Supports `**`, `*`, and literal path segments (e.g. README.md).
4
+ */
5
+
6
+ function globToRegExp(glob) {
7
+ let pattern = '';
8
+ for (let i = 0; i < glob.length; i += 1) {
9
+ const ch = glob[i];
10
+ if (ch === '*' && glob[i + 1] === '*') {
11
+ pattern += '.*';
12
+ i += 1;
13
+ if (glob[i + 1] === '/') i += 1;
14
+ } else if (ch === '*') {
15
+ pattern += '[^/]*';
16
+ } else if (/[+?^${}()|[\]\\]/.test(ch)) {
17
+ pattern += `\\${ch}`;
18
+ } else {
19
+ pattern += ch;
20
+ }
21
+ }
22
+ return new RegExp(`^${pattern}$`);
23
+ }
24
+
25
+ /**
26
+ * @param {string} glob
27
+ * @param {string} filePath
28
+ * @returns {boolean}
29
+ */
30
+ export function matchPathAllowlist(glob, filePath) {
31
+ if (typeof glob !== 'string' || typeof filePath !== 'string') return false;
32
+ const normalized = filePath.replace(/\\/g, '/').replace(/^\.\//, '');
33
+ return globToRegExp(glob).test(normalized);
34
+ }
35
+
36
+ /**
37
+ * @param {string[]} allowedPaths
38
+ * @param {string} filePath
39
+ * @returns {boolean}
40
+ */
41
+ export function isPathAllowed(allowedPaths, filePath) {
42
+ if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return false;
43
+ return allowedPaths.some((glob) => matchPathAllowlist(glob, filePath));
44
+ }
45
+
46
+ /**
47
+ * @param {string[]} allowedPaths
48
+ * @param {string[]} changedPaths
49
+ * @returns {string[]}
50
+ */
51
+ export function pathsOutsideAllowlist(allowedPaths, changedPaths) {
52
+ if (!Array.isArray(changedPaths)) return [];
53
+ return changedPaths.filter((filePath) => !isPathAllowed(allowedPaths, filePath));
54
+ }
55
+
56
+ /**
57
+ * @param {string[]} allowedPaths
58
+ * @param {string[]} changedPaths
59
+ * @returns {boolean}
60
+ */
61
+ export function allPathsAllowed(allowedPaths, changedPaths) {
62
+ return pathsOutsideAllowlist(allowedPaths, changedPaths).length === 0;
63
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from 'zod';
2
+ import { ERROR_CODES, forgeError } from './contracts/errors.js';
3
+
4
+ /** Canonical v1 consumer write command ids (schema + gate single source). */
5
+ export const WRITE_COMMAND_IDS = Object.freeze(['cr_open']);
6
+
7
+ export const writeCommandSchema = z.enum(WRITE_COMMAND_IDS);
8
+
9
+ /** @deprecated use WRITE_COMMAND_IDS */
10
+ export const CONFIGURED_WRITE_COMMANDS = WRITE_COMMAND_IDS;
11
+
12
+ export function isWriteCommandConfigured(config, commandName) {
13
+ if (!writeCommandSchema.safeParse(commandName).success) return false;
14
+ const allowed = config?.write_commands;
15
+ return Array.isArray(allowed) && allowed.includes(commandName);
16
+ }
17
+
18
+ export function assertWriteCommandConfigured(config, commandName) {
19
+ if (!writeCommandSchema.safeParse(commandName).success) {
20
+ throw Object.assign(new Error(`Unknown write command: ${commandName}`), {
21
+ forgeError: forgeError(
22
+ ERROR_CODES.INVALID_ARGS,
23
+ `Unknown write command "${commandName}"; supported: ${WRITE_COMMAND_IDS.join(', ')}`,
24
+ ),
25
+ });
26
+ }
27
+ if (isWriteCommandConfigured(config, commandName)) return;
28
+ throw Object.assign(new Error(`Write command not configured: ${commandName}`), {
29
+ forgeError: forgeError(
30
+ ERROR_CODES.WRITE_NOT_CONFIGURED,
31
+ `Add "${commandName}" to write_commands in .remogram.json to enable this command`,
32
+ ),
33
+ });
34
+ }