@rungs/cli 0.3.0 → 0.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rungs/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Installs and maintains a repository's agentic development system, composed from modules.",
5
5
  "author": "Antoine Dancre",
6
6
  "repository": {
package/src/engines.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { matchAny, walk } from './glob.ts';
4
5
  import { parse as parseToml } from 'smol-toml';
5
6
  import { runSelfTests } from './selftest.ts';
6
7
  import { loadAllModules } from './manifest.ts';
8
+
7
9
  import { resolveParams, substitute } from './substitute.ts';
8
10
  import {
9
11
  computedClaim,
@@ -17,6 +19,23 @@ import {
17
19
  } from './engines2.ts';
18
20
  import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
19
21
 
22
+ /**
23
+ * Where the CLI's own `modules/` lives.
24
+ *
25
+ * This was `new URL(import.meta.url).pathname.slice(1)` in three places. The
26
+ * `.slice(1)` strips a leading `/`, which is right on Windows — `/C:/…` becomes
27
+ * `C:/…` — and **wrong everywhere else**, where `/home/runner/…` becomes the
28
+ * relative `home/runner/…`. On Linux and macOS the directory did not resolve,
29
+ * `loadAllModules` found nothing, and three gates silently lost the data they
30
+ * read from the module set: `skills-spec-pure` and `skills-description-routes`
31
+ * reported every opted-in extension as a non-spec key, and
32
+ * `gates-self-tests-both-directions` reported gates that have fixtures as
33
+ * having none. All three passed here and failed on the first Linux run (F-036).
34
+ *
35
+ * `fileURLToPath` is what the rest of the codebase already used.
36
+ */
37
+ const CLI_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules');
38
+
20
39
  export interface Finding {
21
40
  file?: string;
22
41
  message: string;
@@ -362,7 +381,7 @@ export const gateMeta: Engine = (_t, root) => {
362
381
  if (!id || kind !== 'declared' || !table) continue;
363
382
  examined++;
364
383
  // Tables live in the CLI, not the repo, so read them from the module set.
365
- const tablePath = join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules', dirname(table), 'gates', table.split('/').pop()!);
384
+ const tablePath = join(CLI_MODULES, dirname(table), 'gates', table.split('/').pop()!);
366
385
  const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
367
386
  const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
368
387
  .map((m) => m[0])
@@ -426,7 +445,7 @@ function optedInExtensions(rel: string, spec: any): Set<string> {
426
445
  const name = rel.split('/').slice(-2)[0];
427
446
  if (!name) return new Set();
428
447
  try {
429
- const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
448
+ const mods = loadAllModules(CLI_MODULES);
430
449
  const owner = mods.find((m) => m.skills?.[name]?.extensions);
431
450
  return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
432
451
  } catch {
@@ -450,7 +469,7 @@ function parseTable(path: string, module: string): any | null {
450
469
  // and the runner reported the gate broken — a mismatch entirely of the
451
470
  // harness's making. A fixture and the table it tests must resolve against
452
471
  // the same parameters or neither means anything.
453
- const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
472
+ const mods = loadAllModules(CLI_MODULES);
454
473
  const params = resolveParams(mods, {}, '.');
455
474
  return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
456
475
  } catch {
package/src/engines2.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { matchAny } from './glob.ts';
5
5
  import type { Engine, Finding } from './engines.ts';
@@ -344,12 +344,28 @@ export const crossReference: Engine = (t, root, files) => {
344
344
  * this repo does not use — it deletes branches on merge — and it is the
345
345
  * direction to be wrong in, because the alternative is the daily false positive.
346
346
  */
347
+ /**
348
+ * `git` as an argv array, never a shell string.
349
+ *
350
+ * `--format=%(refname:short)` is a **bash syntax error** — unquoted parentheses —
351
+ * so `backlog-merged-status` threw on every Linux and macOS repo, hit its catch,
352
+ * and reported "cannot read git branches; status not reconciled" as a finding.
353
+ * The gate ships in four of five profiles and had never once worked off Windows,
354
+ * where `execSync` goes through cmd.exe and parentheses are ordinary characters.
355
+ * Found by the CI matrix on its first run (F-033).
356
+ *
357
+ * Branch names come out of work-item frontmatter, so this is also the difference
358
+ * between reading a field and passing it to a shell.
359
+ */
360
+ const gitArgs = (root: string, args: string[]) =>
361
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
362
+
347
363
  function landedWork(root: string, branch: string, base: string): boolean {
348
- const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: root, stdio: 'pipe' }).toString().trim();
364
+ const git = (...args: string[]) => gitArgs(root, args);
349
365
  try {
350
- const tip = git(`rev-parse ${branch}`);
351
- if (tip === git(`rev-parse ${base}`)) return false;
352
- return git(`log ${base} --merges --format=%P`)
366
+ const tip = git('rev-parse', branch);
367
+ if (tip === git('rev-parse', base)) return false;
368
+ return git('log', base, '--merges', '--format=%P')
353
369
  .split('\n')
354
370
  .some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
355
371
  } catch {
@@ -364,11 +380,7 @@ export const gitStatusReconcile: Engine = (t, root, files) => {
364
380
  let merged: Set<string>;
365
381
  try {
366
382
  merged = new Set(
367
- execSync(`git branch --merged ${t.integration_branch ?? 'main'} --format=%(refname:short)`, {
368
- cwd: root,
369
- stdio: 'pipe',
370
- })
371
- .toString()
383
+ gitArgs(root, ['branch', '--merged', t.integration_branch ?? 'main', '--format=%(refname:short)'])
372
384
  .split('\n')
373
385
  .map((s) => s.trim())
374
386
  .filter(Boolean),
package/src/engines3.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { execSync } from 'node:child_process';
2
+ import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { matchAny } from './glob.ts';
5
5
  import type { Engine, Finding } from './engines.ts';
@@ -213,7 +213,7 @@ export const rulePropagation: Engine = (t, root, files) => {
213
213
  export const gitState: Engine = (t, root) => {
214
214
  let out: string;
215
215
  try {
216
- out = execSync('git worktree list --porcelain', { cwd: root, stdio: 'pipe' }).toString();
216
+ out = execFileSync('git', ['worktree', 'list', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
217
217
  } catch {
218
218
  // Not a git repo, or git unavailable. An unattributable result blocks:
219
219
  // we do not land on an unknown.
@@ -248,7 +248,8 @@ export const mergeDriverCheck: Engine = (t, root) => {
248
248
  for (const driver of required) {
249
249
  let configured = '';
250
250
  try {
251
- configured = execSync(`git config --get merge.${driver}.driver`, { cwd: root, stdio: 'pipe' }).toString().trim();
251
+ // Driver names come from `.gitattributes`, so they reach this as data.
252
+ configured = execFileSync('git', ['config', '--get', `merge.${driver}.driver`], { cwd: root, stdio: 'pipe' }).toString().trim();
252
253
  } catch {
253
254
  /* absent config exits non-zero, which is the finding */
254
255
  }
package/src/lifecycle.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
- import { execSync } from 'node:child_process';
4
+ import { execFileSync } from 'node:child_process';
5
5
  import { parse } from 'smol-toml';
6
6
  import type { Manifest } from './types.ts';
7
7
  import { contentHash, emittedFiles, registerGates } from './add.ts';
@@ -334,8 +334,13 @@ export function setupGit(repoRoot: string, dryRun = false) {
334
334
  : 'git merge-file -L ours -L base -L theirs %A %O %B';
335
335
  if (!dryRun) {
336
336
  try {
337
- execSync(`git config merge.${d}.name "rungs ${d.replace('rungs-', '')} driver"`, { cwd: repoRoot, stdio: 'pipe' });
338
- execSync(`git config merge.${d}.driver ${JSON.stringify(cmd)}`, { cwd: repoRoot, stdio: 'pipe' });
337
+ // argv, not a shell string. The `rungs-generated` driver command carries
338
+ // single quotes, a literal `\n` and `%A %O %B`, and it was being handed
339
+ // to a shell through `JSON.stringify` — quoting that happens to survive
340
+ // cmd.exe and does not survive bash the same way. The same class of bug
341
+ // as F-033, found in the same sweep.
342
+ execFileSync('git', ['config', `merge.${d}.name`, `rungs ${d.replace('rungs-', '')} driver`], { cwd: repoRoot, stdio: 'pipe' });
343
+ execFileSync('git', ['config', `merge.${d}.driver`, cmd], { cwd: repoRoot, stdio: 'pipe' });
339
344
  } catch {
340
345
  continue;
341
346
  }
@@ -345,7 +350,7 @@ export function setupGit(repoRoot: string, dryRun = false) {
345
350
  let rerere = false;
346
351
  if (!dryRun) {
347
352
  try {
348
- execSync('git config rerere.enabled true', { cwd: repoRoot, stdio: 'pipe' });
353
+ execFileSync('git', ['config', 'rerere.enabled', 'true'], { cwd: repoRoot, stdio: 'pipe' });
349
354
  rerere = true;
350
355
  } catch {
351
356
  /* not a git repo */