@codedology/code-forge 0.6.0 → 0.6.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.
@@ -15,7 +15,9 @@
15
15
  * (`err.pos`) and the source string — never from anything the `yaml` package formats for display.
16
16
  */
17
17
 
18
+ import { existsSync, realpathSync, statSync } from 'node:fs';
18
19
  import { readFile } from 'node:fs/promises';
20
+ import os from 'node:os';
19
21
  import path from 'node:path';
20
22
  import { parse as parseYAML } from 'yaml';
21
23
  import { migrateConfig } from './migrate.mjs';
@@ -131,20 +133,115 @@ export async function loadConfigFile(filePath) {
131
133
  * @returns {Promise<LoadResult>}
132
134
  */
133
135
  export async function loadProjectConfig(cwd = process.cwd()) {
134
- return loadConfigFile(path.join(cwd, DEFAULT_CONFIG_FILENAME));
136
+ // B50: always the PROJECT ROOT's config ({@link projectRootFor}; idempotent when `cwd` is the
137
+ // root). A symlinked `.code-forge.yml` (dotfiles) loads like a file; a directory there is a `read-error`.
138
+ return loadConfigFile(path.join(projectRootFor(cwd), DEFAULT_CONFIG_FILENAME));
135
139
  }
136
140
 
137
141
  /**
138
- * `project.slug` from config, else the workspace directory name as a ledger slug. The configured
142
+ * The nearest ancestor of `start` (itself included) that holds `marker`, or null.
143
+ * @param {string} start @param {string} marker
144
+ * @returns {string | null}
145
+ */
146
+ function nearestWith(start, marker) {
147
+ for (let dir = start; ; dir = path.dirname(dir)) {
148
+ if (existsSync(path.join(dir, marker))) return dir;
149
+ if (path.dirname(dir) === dir) return null;
150
+ }
151
+ }
152
+
153
+ /** @param {string} p @returns {boolean} true when `p` is (or links to) a regular file — a symlinked config (dotfiles) counts, a directory never. */
154
+ function isRegularFile(p) {
155
+ try {
156
+ return statSync(p).isFile();
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /** @param {string} p @returns {string} `p` realpath'd, or resolved as given when it cannot be. */
163
+ function realOrResolved(p) {
164
+ try {
165
+ return realpathSync(p);
166
+ } catch {
167
+ return path.resolve(p);
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The project root a verb run from `start` belongs to (B50). The git top level is found first
173
+ * (the nearest ancestor holding a `.git` directory or worktree file — a filesystem walk, no child
174
+ * `git`); the root is then the nearest folder from `start` up to AND INCLUDING that top level whose
175
+ * `.code-forge.yml` is a regular file (a symlink to one counts; a directory never), else the top
176
+ * level itself — a config above the repository never counts. Outside git, the same search runs
177
+ * from `start` up to the home directory (never past it) when `start` is under it, and looks at
178
+ * `start` alone when it is not (`/tmp/x`, `/opt/ci`: a stray config higher up never counts); found
179
+ * nothing ⇒ the root is `start`. Every walk runs on realpaths; the root comes back in the caller's
180
+ * spelling when an ancestor of the start as given resolves to it ({@link inCallerSpelling}). A verb
181
+ * run from a subfolder thus finds the project's config and ledger slug. Synchronous (no child process).
182
+ * @param {string} [start] - defaults to `process.cwd()`.
183
+ * @returns {string} absolute.
184
+ */
185
+ export function projectRootFor(start = process.cwd()) {
186
+ return inCallerSpelling(start, resolvedRootFor(realOrResolved(start)));
187
+ }
188
+
189
+ /**
190
+ * The root on realpaths: the git walk, the config walk and the HOME check all run on the resolved
191
+ * start, so a symlink into a repository finds its `.git`.
192
+ * @param {string} from - the realpath of the start.
193
+ * @returns {string} a realpath.
194
+ */
195
+ function resolvedRootFor(from) {
196
+ const home = realOrResolved(os.homedir());
197
+ // a `.git` at HOME itself (a dotfiles repository) never makes HOME the project: outside git
198
+ const gitRoot = nearestWith(from, '.git');
199
+ if (gitRoot !== null && gitRoot !== home) return nearestConfigUpTo(from, gitRoot) ?? gitRoot;
200
+ const underHome = from === home || from.startsWith(`${home}${path.sep}`);
201
+ if (!underHome) return from; // the start alone: its own config (if any) or not, the root is the start
202
+ return nearestConfigUpTo(from, home) ?? from;
203
+ }
204
+
205
+ /**
206
+ * The root in the caller's spelling when it can be: the ancestor of `path.resolve(start)` (itself
207
+ * included) whose realpath IS the resolved root (`~/work/app` → `/data/app-v2` keeps `app`, and its
208
+ * slug); none ⇒ the realpath itself.
209
+ * @param {string} start @param {string} realRoot
210
+ * @returns {string}
211
+ */
212
+ function inCallerSpelling(start, realRoot) {
213
+ for (let dir = path.resolve(start); ; dir = path.dirname(dir)) {
214
+ if (realOrResolved(dir) === realRoot) return dir;
215
+ if (path.dirname(dir) === dir) return realRoot;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The nearest folder from `from` up to and including `stop` whose `.code-forge.yml` is a regular file.
221
+ * @param {string} from @param {string} stop - an ancestor of `from` (or `from` itself).
222
+ * @returns {string | null}
223
+ */
224
+ function nearestConfigUpTo(from, stop) {
225
+ for (let dir = from; ; dir = path.dirname(dir)) {
226
+ if (isRegularFile(path.join(dir, DEFAULT_CONFIG_FILENAME))) return dir;
227
+ if (dir === stop || path.dirname(dir) === dir) return null;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * `project.slug` from config, else the PROJECT ROOT's directory name ({@link projectRootFor} of
233
+ * `workspace`, B50: a subfolder never names the ledger) as a ledger slug. The configured
139
234
  * value is NOT trusted here: `startRun` refuses any slug outside `/^[a-z0-9][a-z0-9-]*$/`
140
235
  * (`bad-project`) before anything is written, so `../x` never reaches the ledger path. Run ids
141
236
  * are checked the same way by the state layer (`assertRunId`, `bad-run-id`).
237
+ * `cfg` MUST be the config loaded from `projectRootFor(workspace)` (never the cwd's), or a
238
+ * configured `project.slug` is lost from a subfolder and the ledger splits.
142
239
  * @param {Record<string, any>} cfg @param {string} workspace
143
240
  * @returns {string}
144
241
  */
145
242
  export function slugFor(cfg, workspace) {
146
243
  const configured = cfg?.project?.slug;
147
244
  if (typeof configured === 'string' && configured.length > 0) return configured;
148
- const derived = path.basename(workspace).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
245
+ const derived = path.basename(projectRootFor(workspace)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
149
246
  return derived || 'project';
150
247
  }