@remits/remits-cli 0.1.122 → 0.1.124

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/index.js CHANGED
@@ -1819,6 +1819,19 @@ function resolveSessionContext(cwd, flags) {
1819
1819
  };
1820
1820
  }
1821
1821
 
1822
+ /**
1823
+ * Record the FILE identity of a component (type + filename id, or type + filename stem) on an object without
1824
+ * sending it anywhere. Non-enumerable, so JSON payloads and the content hash never see it.
1825
+ *
1826
+ * It exists because a component is named twice: by its files, and by its `.meta.yml` `name:`. For a `new_`
1827
+ * file there is no id, so the two can disagree (`new_PDFStatement` vs `name: PDF Statement`), and the file
1828
+ * identity is the only thing both the payload and the git changed set agree on.
1829
+ */
1830
+ function withFileKey(target, key) {
1831
+ Object.defineProperty(target, 'fileKey', { value: key, enumerable: false, configurable: true });
1832
+ return target;
1833
+ }
1834
+
1822
1835
  function collectComponents(cwd) {
1823
1836
  const mapping = {
1824
1837
  schemas: 'schema',
@@ -1866,7 +1879,7 @@ function collectComponents(cwd) {
1866
1879
  //
1867
1880
  // A partial stage (mcp_component_edit writing one field) deliberately does NOT set this and
1868
1881
  // keeps the merge semantics it needs.
1869
- byKey.set(key, { type, id, name, metadataAuthoritative: true });
1882
+ byKey.set(key, withFileKey({ type, id, name, metadataAuthoritative: true }, key));
1870
1883
  }
1871
1884
  const component = byKey.get(key);
1872
1885
  const filePath = path.join(dir, fileName);
@@ -2033,11 +2046,11 @@ function changedComponentsFromWorkingTree(cwd) {
2033
2046
  if (!entry.statuses.includes(status)) entry.statuses.push(status);
2034
2047
  if (!entry.paths.includes(info.path)) entry.paths.push(info.path);
2035
2048
  }
2036
- return Array.from(byKey.values()).map((entry) => ({
2049
+ return Array.from(byKey.entries()).map(([key, entry]) => withFileKey({
2037
2050
  ...entry,
2038
2051
  fields: entry.fields.sort(),
2039
2052
  paths: entry.paths.sort()
2040
- })).sort((a, b) => {
2053
+ }, key)).sort((a, b) => {
2041
2054
  const typeCmp = String(a.type).localeCompare(String(b.type));
2042
2055
  if (typeCmp !== 0) return typeCmp;
2043
2056
  return String(a.id || a.name || '').localeCompare(String(b.id || b.name || ''));
@@ -2071,7 +2084,7 @@ function changedComponentsSinceRef(cwd, ref) {
2071
2084
  const info = componentPathInfo(cwd, filePath);
2072
2085
  if (!info) continue;
2073
2086
  if (!byKey.has(info.key)) {
2074
- byKey.set(info.key, { type: info.type, id: info.id, name: info.name, fields: [], paths: [] });
2087
+ byKey.set(info.key, withFileKey({ type: info.type, id: info.id, name: info.name, fields: [], paths: [] }, info.key));
2075
2088
  }
2076
2089
  const entry = byKey.get(info.key);
2077
2090
  if (!entry.fields.includes(info.field)) entry.fields.push(info.field);
@@ -2759,7 +2772,7 @@ async function pushComponentsCommand(flags) {
2759
2772
  const dataMode = resolveDataMode(flags, session);
2760
2773
  const requestedMode = String(flags.mode || 'stage').toLowerCase();
2761
2774
  const mode = requestedMode === 'push' ? 'stage' : requestedMode;
2762
- const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
2775
+ const changedFromGit = changedComponentsFromWorkingTree(cwd);
2763
2776
 
2764
2777
  // WHAT this stage is. Three shapes, and the difference between them is the difference between a lane
2765
2778
  // that reads as "7 components in flight" and one that reads as "115 staged":
@@ -2779,6 +2792,8 @@ async function pushComponentsCommand(flags) {
2779
2792
  const emptyWorksetPolicy = normalizeEmptyWorksetPolicy(flags);
2780
2793
 
2781
2794
  let components = collectComponents(cwd);
2795
+ // Named the way the payload is named, BEFORE anything compares the two — see alignChangedSetWithComponents.
2796
+ const changedFromWorkingTree = alignChangedSetWithComponents(changedFromGit, components);
2782
2797
  // Changes git reported that a component payload cannot carry — a deleted component file has nothing to
2783
2798
  // stage, and Redis staging has no way to say "hide this during CLI-scoped runs". Removing its staged
2784
2799
  // entry falls back to the committed row, so the component still resolves. Reported out loud rather than
@@ -2788,10 +2803,8 @@ async function pushComponentsCommand(flags) {
2788
2803
  if (changedFromWorkingTree === null) {
2789
2804
  throw new Error('--changed-only needs a git working tree to establish the changed set, and this directory is not one.');
2790
2805
  }
2791
- const wanted = new Set(changedFromWorkingTree.map((entry) =>
2792
- entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())));
2793
- components = components.filter((component) => wanted.has(
2794
- component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2806
+ const wanted = new Set(changedFromWorkingTree.map(stageIdentityKey));
2807
+ components = components.filter((component) => wanted.has(stageIdentityKey(component)));
2795
2808
  if (!components.length && !(worksetReplace && emptyWorksetPolicy === 'clear')) {
2796
2809
  // Deliberately NOT an error, and deliberately not a clear. An agent that has not edited anything yet
2797
2810
  // is in an ordinary state; failing its loop teaches it nothing, and reconciling the lane to an empty
@@ -2943,16 +2956,71 @@ function normalizeEmptyWorksetPolicy(flags) {
2943
2956
  *
2944
2957
  * Returns [] when git could not answer, because "nothing is unrepresentable" would be a claim.
2945
2958
  */
2959
+ /**
2960
+ * The ONE identity a stage compares components by: type + id, or type + name for an id-less `new_` file.
2961
+ * The workset filter and the unrepresentable check used to spell this out separately.
2962
+ */
2963
+ function stageIdentityKey(entry) {
2964
+ return entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase());
2965
+ }
2966
+
2967
+ /**
2968
+ * Name each git-changed component the way its payload is named.
2969
+ *
2970
+ * The changed set is built from FILENAMES; the payload takes `name` from the `.meta.yml`. For a `new_` file
2971
+ * there is no id, so `new_PDFStatement.groovy` + `name: PDF Statement` produced two identities for one
2972
+ * component: `--workset` filtered it out, it was reported as "no-local-files", and the platform (which
2973
+ * matches the changed set against the stored name) never marked it as workset — so a full snapshot staged
2974
+ * it WITHOUT compiling it. Both sides carry the file identity (`fileKey`), so align on that.
2975
+ */
2976
+ function alignChangedSetWithComponents(changed, components) {
2977
+ if (!Array.isArray(changed)) return changed;
2978
+ const byFileKey = new Map();
2979
+ (components || []).forEach((component) => {
2980
+ if (component && component.fileKey) byFileKey.set(component.fileKey, component);
2981
+ });
2982
+ return changed.map((entry) => {
2983
+ const component = entry && entry.fileKey ? byFileKey.get(entry.fileKey) : null;
2984
+ if (!component || entry.id || !component.name || component.name === entry.name) return entry;
2985
+ return withFileKey(Object.assign({}, entry, { name: component.name }), entry.fileKey);
2986
+ });
2987
+ }
2988
+
2989
+ /**
2990
+ * The local components, for identity alignment ONLY on paths that did not already collect them (the sync
2991
+ * gates). Best-effort: a sidecar that does not parse must not stop a sync the server is about to judge.
2992
+ */
2993
+ function collectComponentsForIdentity(cwd) {
2994
+ try {
2995
+ return collectComponents(cwd);
2996
+ } catch (_) {
2997
+ return [];
2998
+ }
2999
+ }
3000
+
2946
3001
  function unrepresentableChanges(changedFromWorkingTree, collected) {
2947
3002
  if (!Array.isArray(changedFromWorkingTree)) return [];
2948
- const present = new Set((collected || []).map((component) =>
2949
- component.type + ':' + (component.id ? 'id:' + component.id : 'name:' + String(component.name || '').toLowerCase())));
2950
- return changedFromWorkingTree
2951
- .filter((entry) => !present.has(entry.type + ':' + (entry.id ? 'id:' + entry.id : 'name:' + String(entry.name || '').toLowerCase())))
2952
- .map((entry) => Object.assign({}, entry, {
2953
- unrepresentable: true,
2954
- reason: (entry.statuses || []).some((s) => String(s).includes('D')) ? 'deleted' : 'no-local-files'
2955
- }));
3003
+ const contentFields = ['source', 'prompt', 'html', 'javascript', 'previewData', 'inputSchema', 'messages', 'schema'];
3004
+ const present = new Map((collected || []).map((component) => [stageIdentityKey(component), component]));
3005
+ const report = [];
3006
+ changedFromWorkingTree.forEach((entry) => {
3007
+ const deleted = (entry.statuses || []).some((s) => String(s).includes('D'));
3008
+ const component = present.get(stageIdentityKey(entry));
3009
+ if (!component) {
3010
+ report.push(Object.assign({}, entry, { unrepresentable: true, reason: deleted ? 'deleted' : 'no-local-files' }));
3011
+ return;
3012
+ }
3013
+ // The component is still present (its other files remain), so it IS staged — but a content file git
3014
+ // reports as deleted has nothing to send, and staged content fields LAYER: the lane keeps whatever it
3015
+ // held before. Reported alongside the component, which stays in the workset.
3016
+ const deletedFields = deleted
3017
+ ? (entry.fields || []).filter((field) => contentFields.includes(field) && component[field] == null)
3018
+ : [];
3019
+ if (deletedFields.length) {
3020
+ report.push(Object.assign({}, entry, { unrepresentable: true, reason: 'content-file-deleted', fields: deletedFields }));
3021
+ }
3022
+ });
3023
+ return report;
2956
3024
  }
2957
3025
 
2958
3026
  function printUnrepresentableChanges(unstageable) {
@@ -2960,12 +3028,23 @@ function printUnrepresentableChanges(unstageable) {
2960
3028
  console.log('');
2961
3029
  console.log('NOT REPRESENTABLE IN REDIS STAGING — ' + unstageable.length + ' change(s):');
2962
3030
  unstageable.slice(0, 20).forEach((entry) => {
3031
+ const fields = Array.isArray(entry.fields) && entry.reason === 'content-file-deleted' ? ': ' + entry.fields.join(', ') : '';
2963
3032
  console.log(' ' + entry.type + ' ' + (entry.id || entry.name || '(unknown)') +
2964
- ' (' + entry.reason + ') ' + (entry.paths || []).join(', '));
3033
+ ' (' + entry.reason + fields + ') ' + (entry.paths || []).join(', '));
2965
3034
  });
2966
3035
  console.log(' A staged entry cannot hide a component. Clearing it falls back to the committed row, so');
2967
3036
  console.log(' the component still resolves in a CLI-scoped run. Prove a deletion through the durable');
2968
3037
  console.log(' plan instead: remits-cli components sync --dry-run --summary --fail-on-errors');
3038
+ const partial = unstageable.filter((entry) => entry.reason === 'content-file-deleted');
3039
+ if (partial.length) {
3040
+ console.log(' A deleted CONTENT file of a component that is still present is not a staged removal: the');
3041
+ console.log(' lane keeps the content it already held, and compile validation judges THAT. Drop it with:');
3042
+ partial.slice(0, 5).forEach((entry) => {
3043
+ console.log(entry.id
3044
+ ? ' remits-cli components clear --component-type ' + entry.type + ' --component-id ' + entry.id
3045
+ : ' remits-cli components clear --all (' + entry.type + ' ' + entry.name + ' has no id; this clears only your lane)');
3046
+ });
3047
+ }
2969
3048
  }
2970
3049
 
2971
3050
  /**
@@ -2995,6 +3074,10 @@ async function stageOrRefuse(api, cwd, payload) {
2995
3074
  // over a stage with nine broken components fixes one, re-stages, and pays nine round trips. Render
2996
3075
  // the whole list HERE, where the body is still in hand, then throw the one-line summary.
2997
3076
  printCompileValidation(body.compileValidation, { stderr: true });
3077
+ // A refusal can be ABOUT an unrepresentable change: a deleted source file leaves the lane holding the
3078
+ // content it had, and that is what was judged. Without this the refusal names a file that no longer
3079
+ // exists and "fix it and re-stage" cannot work. Read from the payload, which still carries `fields`.
3080
+ printUnrepresentableChanges((payload.changedSet || []).filter((entry) => entry && entry.unrepresentable));
2998
3081
  const failure = new Error(body.message);
2999
3082
  failure.responseBody = body;
3000
3083
  failure.status = status;
@@ -3052,6 +3135,13 @@ function printCompileValidation(validation, options) {
3052
3135
  emit('Compile validation: ' + status + ' — ' + validation.passed + '/' + validation.attempted +
3053
3136
  ' compiled in ' + validation.durationMs + 'ms' +
3054
3137
  (validation.concurrency ? ' (' + validation.concurrency + ' parallel)' : ''));
3138
+ // A skip is neither a pass nor a failure, and it used to be printed as a pass ("1/1 compiled" over a
3139
+ // component whose source was blank). Named, so the reader knows exactly what was NOT looked at.
3140
+ const unchecked = Array.isArray(validation.unchecked) ? validation.unchecked : [];
3141
+ if (unchecked.length) {
3142
+ emit(' ' + unchecked.length + ' NOT compile-checked: ' + unchecked.map((entry) =>
3143
+ (entry.type || 'component') + ' ' + (entry.id || entry.name || '?') + ' (' + (entry.reason || 'unchecked') + ')').join(', '));
3144
+ }
3055
3145
  const shown = failures.slice(0, COMPILE_FAILURES_SHOWN);
3056
3146
  shown.forEach((failure) => {
3057
3147
  emit(' ' + (failure.type || 'component') + ' ' + (failure.id || failure.name || '') +
@@ -3692,7 +3782,9 @@ async function syncComponentsCommand(rawFlags) {
3692
3782
  const forceTombstones = flagEnabled(flags['force-tombstones']) || flagEnabled(flags.forceTombstones);
3693
3783
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
3694
3784
  const api = buildAxios(baseUrl, session.token);
3695
- const changedFromWorkingTree = changedComponentsFromWorkingTree(cwd);
3785
+ // Aligned for the same reason a stage is: the server's plan names a `new_` component by its sidecar
3786
+ // `name:`, and the changed set would otherwise name it by its filename and the gate would refuse it.
3787
+ const changedFromWorkingTree = alignChangedSetWithComponents(changedComponentsFromWorkingTree(cwd), collectComponentsForIdentity(cwd));
3696
3788
  const preflightRequested = syncPreflightRequested(flags);
3697
3789
  const namesOnly = flagEnabled(flags['names-only']) || flagEnabled(flags.namesOnly);
3698
3790
 
@@ -4100,7 +4192,7 @@ function evaluateSyncGates(response, flags, changedFromWorkingTree, cwd) {
4100
4192
 
4101
4193
  if (flagEnabled(flags['changed-only']) || flagEnabled(flags.changedOnly)) {
4102
4194
  const since = flags['changed-since'] || flags.changedSince;
4103
- const fromRef = since ? changedComponentsSinceRef(cwd, String(since)) : null;
4195
+ const fromRef = since ? alignChangedSetWithComponents(changedComponentsSinceRef(cwd, String(since)), collectComponentsForIdentity(cwd)) : null;
4104
4196
  if (since && fromRef === null) {
4105
4197
  violations.push('--changed-since: could not diff against "' + since + '". Check the ref exists (git fetch first for a remote ref).');
4106
4198
  checks.changedOnly = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.122",
3
+ "version": "0.1.124",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -235,6 +235,11 @@ Two things to read correctly when it refuses:
235
235
  lease) and writes nothing at all.
236
236
  - **Every failure is printed, not just the first.** The one-line error is the first failure; the full list
237
237
  (identified by type and id) follows it. Fix them in one pass rather than one round trip each.
238
+ - **`N NOT compile-checked` is not a pass.** A `new_` component whose source file is empty or blank cannot
239
+ be judged, so it is listed by name with its reason instead of being counted as compiled. It will not run.
240
+ - **Deleting a component's source file while its sidecar stays is `content-file-deleted`.** The lane keeps
241
+ the source it already held, and that is what gets compiled and run. Clear that one component with the
242
+ command the CLI prints (`components clear --component-type <type> --component-id <id>`).
238
243
 
239
244
  If git cannot identify the changed set — you are not in a working tree, or git failed — there is no
240
245
  workset to validate, and the CLI prints `Compile validation: NOT RUN — workset-unknown` rather than