@jwilger/pi-development-system 0.72.0 → 0.74.0

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": "@jwilger/pi-development-system",
3
- "version": "0.72.0",
3
+ "version": "0.74.0",
4
4
  "description": "A pi extension package representing a seasoned approach to software development using a full AI SDLC.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -1,5 +1,5 @@
1
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
- import { join } from "node:path";
1
+ import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
3
  import { ok } from "../core/result.ts";
4
4
  import { parseItem, renderBacklog, renderItem, unwritable } from "./item-format.ts";
5
5
  import {
@@ -36,8 +36,35 @@ const byCodeUnit = (a: string, b: string): number => Number(a > b) - Number(a <
36
36
  const describe = (cause: unknown): string =>
37
37
  cause instanceof Error ? cause.message : String(cause);
38
38
 
39
+ /**
40
+ * The first symlink on the way from the repo root to `target`, if any. A committed symlink
41
+ * under `work/` could otherwise make a tracker write land outside the repository.
42
+ */
43
+ const symlinkOnPath = async (root: string, target: string): Promise<string | undefined> => {
44
+ let current = root;
45
+ for (const part of relative(root, target).split(sep)) {
46
+ current = join(current, part);
47
+ try {
48
+ if ((await lstat(current)).isSymbolicLink()) return current;
49
+ } catch (cause) {
50
+ if (isMissing(cause)) return undefined;
51
+ throw cause;
52
+ }
53
+ }
54
+ return undefined;
55
+ };
56
+
57
+ const refuseSymlink = async (root: string, target: string): Promise<string | undefined> => {
58
+ const link = await symlinkOnPath(root, target);
59
+ return link === undefined
60
+ ? undefined
61
+ : `${relative(root, link)} is a symbolic link; the repo-files tracker will not follow it`;
62
+ };
63
+
39
64
  const readItem = async (root: string, id: string): Promise<TrackerResult<WorkItem>> => {
40
65
  if (!ID.test(id)) return trackerError(`work item "${id}": not a valid id`);
66
+ const link = await refuseSymlink(root, fileOf(root, id));
67
+ if (link !== undefined) return trackerError(link);
41
68
  try {
42
69
  return parseItem(id, await readFile(fileOf(root, id), "utf8"));
43
70
  } catch (cause) {
@@ -59,6 +86,8 @@ const itemIds = async (root: string): Promise<string[]> => {
59
86
  };
60
87
 
61
88
  const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
89
+ const link = await refuseSymlink(root, itemsDir(root));
90
+ if (link !== undefined) return trackerError(link);
62
91
  const items: WorkItem[] = [];
63
92
  for (const id of (await itemIds(root)).toSorted(byCodeUnit)) {
64
93
  const item = await readItem(root, id);
@@ -71,6 +100,11 @@ const readAll = async (root: string): Promise<TrackerResult<WorkItem[]>> => {
71
100
  const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkItem>> => {
72
101
  const problem = unwritable(item);
73
102
  if (problem !== undefined) return trackerError(problem);
103
+ const backlog = join(root, "work", "backlog.md");
104
+ for (const target of [itemsDir(root), fileOf(root, item.id), backlog]) {
105
+ const link = await refuseSymlink(root, target);
106
+ if (link !== undefined) return trackerError(link);
107
+ }
74
108
  const others = await readAll(root);
75
109
  if (!others.ok) return others;
76
110
  await mkdir(itemsDir(root), { recursive: true });
@@ -78,7 +112,7 @@ const save = async (root: string, item: WorkItem): Promise<TrackerResult<WorkIte
78
112
  const items = [...others.value.filter((i) => i.id !== item.id), item].toSorted((a, b) =>
79
113
  byCodeUnit(a.id, b.id),
80
114
  );
81
- await writeFile(join(root, "work", "backlog.md"), renderBacklog(items));
115
+ await writeFile(backlog, renderBacklog(items));
82
116
  return ok(item);
83
117
  };
84
118