Files
stack/packages/project/src/paths.mjs
T
jason.woltjeandClaude Opus 5.5 d4ea7e5a69 webui, bus, project: a Console editor for the project's spec and docs (row 148, #1642)
Row 148 round 3, Researcher's candidate (agents/researcher/work/queue-148/,
row148.patch 1b63164b, manifest 95ae4736, 27 paths, base 18aee8fa1).
Dewey approved round 3 (comment 28832) and Filbert approved it with no
findings (28835). Mos gave the decision 123 go on public diff 8360122b
(mos-0636): no vendored code, no innerHTML or other HTML sinks.

The Docs page #/docs lists each project's spec/north-star.md and the
Markdown files directly under docs/. Save writes the typed text as one
local commit authored by business.human, through a new human-only bus
verb project.file.save that carries the opened commit as base and
refuses stale-base if the file moved. The preview is a textarea plus a
renderer built with createElement and textContent only; raw HTML shows
as source text and images never load. CSP is unchanged.

Round 3 replaced the backreference HR regex, which overflowed the
regexp stack, with an isRule scan. Round 4 added a textContent fallback
when paintDoc throws, and the submit handler resets saving in a finally.
Mos's rule (ceiling 1 s at 1 MiB, growth x3 per doubling over 1 to
4 MiB): 130/130 and 138/138 cells pass, worst 513 ms at 1 MiB, worst
ratio x2.50, control x2.01; the 756-cell grid has 0 timeouts. Mutants
43/44, v21 equivalent.

Follow-ups: Vikunja P19 tasks 318 (Save stays off after a non-render
paint throw), 319 (dead NUL check in bus.js) and 320 (the 1 MiB + 64 KiB
buffer before the human proof, Gitea #1650).

Sage's landing gate ran on a4779d2a5 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), 22:19:24Z to 22:24:09Z,
network sage-r148g_default removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 17:24:39 -05:00

49 lines
2.2 KiB
JavaScript

import { lstatSync } from 'node:fs';
import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path';
export class ProjectError extends Error {
constructor(code, message) { super(`${code}: ${message}`); this.code = code; }
}
export const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
export const checkedId = value => {
if (typeof value !== 'string' || !ID.test(value)) throw new ProjectError('project-id', 'invalid id');
return value;
};
// The files a person opens and saves in the Console (row 148): the spec, and Markdown directly
// under docs/. Reads and the human writer share this one definition.
export const documentPath = path => typeof path === 'string' &&
(path === 'spec/north-star.md' || /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path));
export function absolute(path) {
if (typeof path !== 'string' || !isAbsolute(path) || normalize(path) !== path || path.includes('\0'))
throw new ProjectError('project-path', 'expected normalized absolute path');
return path;
}
export function noLinks(path) {
for (let part = path; ; part = dirname(part)) {
try {
const st = lstatSync(part);
if (!st.isDirectory() || st.isSymbolicLink()) throw new ProjectError('project-path', 'path ancestor must be a directory, never a link');
} catch (e) { if (e.code !== 'ENOENT') throw e; }
if (part === dirname(part)) break;
}
}
export function projectRepo({ business, project, dataRoot }) {
checkedId(business?.id); checkedId(project);
if (!Object.hasOwn(business?.projects ?? {}, project))
throw new ProjectError('project-not-declared', `business does not declare ${project}`);
absolute(dataRoot);
const entry = business.projects[project];
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
const contains = (base, child) => {
const rel = relative(base, child);
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
};
for (const project of Object.values(business.projects)) {
const code = absolute(project.root);
if (contains(code, repo) || contains(repo, code))
throw new ProjectError('project-path', 'record repository and code checkout overlap');
}
noLinks(repo);
return repo;
}