@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3
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/README.md +13 -5
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
package/dist/staging.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* The staging machinery adopt mode's two stagers share --
|
|
5
|
+
* `baseline-stage.ts` (`.groundwork/baseline/`) and `pack-stage.ts`
|
|
6
|
+
* (`.groundwork/packs/`): the inert `.staged` naming convention, the
|
|
7
|
+
* template-tree walk that maps a source file to its install path, the
|
|
8
|
+
* stale-work-dir sweep, and the atomic write-then-swap lifecycle. Each
|
|
9
|
+
* stager owns its own plan validation and its own result shape; everything
|
|
10
|
+
* here is parameterized by the staging directory's name and a noun for
|
|
11
|
+
* messages, so both stagers' failures read the same way.
|
|
12
|
+
*/
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
17
|
+
import { restoreDotfilePath } from "./assets.js";
|
|
18
|
+
import { isPathContained } from "./emit.js";
|
|
19
|
+
import { assertNotSymlink } from "./fs-guard.js";
|
|
20
|
+
import { applyTokens } from "./tokens.js";
|
|
21
|
+
// `toPosixPath` lives in assets.ts, so inventory.ts can use it without
|
|
22
|
+
// pulling in this stager; re-exported for this module's existing consumers.
|
|
23
|
+
export { toPosixPath } from "./assets.js";
|
|
24
|
+
/**
|
|
25
|
+
* The suffix every staged file carries, so no extension-based glob
|
|
26
|
+
* (`**\/*.ts`, `**\/*.md`, `vitest.config.*`) ever matches a staged copy.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* const staged = `eslint.config.js${STAGED_SUFFIX}`; // "eslint.config.js.staged"
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export const STAGED_SUFFIX = ".staged";
|
|
34
|
+
/**
|
|
35
|
+
* The staged name for a project-relative `path`: `path` + {@link STAGED_SUFFIX}.
|
|
36
|
+
* The single derivation every stager and adopt mode's write-scope check use.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* stagedNameFor("src/index.ts"); // "src/index.ts.staged"
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function stagedNameFor(path) {
|
|
44
|
+
return `${path}${STAGED_SUFFIX}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Finds the first pair of staged paths that would land on the same file on
|
|
48
|
+
* some supported file system: two paths equal once folded -- NFC-normalized
|
|
49
|
+
* (macOS's APFS treats a precomposed `é` and `e` + U+0301 as one name) and
|
|
50
|
+
* case-folded (macOS and Windows default to case-insensitive) -- or one path
|
|
51
|
+
* a proper directory prefix of another once folded (`x.staged` as both a
|
|
52
|
+
* file and the directory holding `x.staged/y.staged`). Either collision
|
|
53
|
+
* makes the second exclusive (`wx`) write fail mid-staging; a plan checks
|
|
54
|
+
* for it first so the defect surfaces as its own error instead. Folding is
|
|
55
|
+
* `toLowerCase()` after `normalize("NFC")`, not a full Unicode case fold, so
|
|
56
|
+
* it is a best-effort approximation of each file system's own rules. Paths
|
|
57
|
+
* are otherwise compared as given -- pass them `/`-separated
|
|
58
|
+
* ({@link toPosixPath}).
|
|
59
|
+
*
|
|
60
|
+
* @returns A description naming both colliding paths, or `undefined` when
|
|
61
|
+
* none collide.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* findStagedPathCollision(["README.md.staged", "readme.md.staged"]); // "README.md.staged and readme.md.staged …"
|
|
66
|
+
* findStagedPathCollision(["a.staged", "b.staged"]); // undefined
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function findStagedPathCollision(stagedPaths) {
|
|
70
|
+
const fold = (p) => p.normalize("NFC").toLowerCase();
|
|
71
|
+
const byFolded = new Map();
|
|
72
|
+
for (const path of stagedPaths) {
|
|
73
|
+
const folded = fold(path);
|
|
74
|
+
const previous = byFolded.get(folded);
|
|
75
|
+
if (previous !== undefined) {
|
|
76
|
+
return previous.toLowerCase() === path.toLowerCase()
|
|
77
|
+
? `${previous} and ${path} differ only by letter case, so they are the same file on a case-insensitive file system`
|
|
78
|
+
: `${previous} and ${path} differ only by Unicode normalization (and possibly letter case), so they are the same file on a normalization-insensitive file system`;
|
|
79
|
+
}
|
|
80
|
+
byFolded.set(folded, path);
|
|
81
|
+
}
|
|
82
|
+
for (const path of stagedPaths) {
|
|
83
|
+
const segments = fold(path).split("/");
|
|
84
|
+
for (let i = 1; i < segments.length; i++) {
|
|
85
|
+
const ancestor = byFolded.get(segments.slice(0, i).join("/"));
|
|
86
|
+
if (ancestor !== undefined) {
|
|
87
|
+
return `${ancestor} would be both a file and the directory holding ${path}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
function collectInto(root, currentDir, tokens, results) {
|
|
94
|
+
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
|
|
95
|
+
const sourcePath = join(currentDir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) {
|
|
97
|
+
collectInto(root, sourcePath, tokens, results);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const installPath = restoreDotfilePath(applyTokens(relative(root, sourcePath), tokens));
|
|
101
|
+
const previous = results.get(installPath);
|
|
102
|
+
if (previous !== undefined) {
|
|
103
|
+
throw new Error(`template files ${previous} and ${sourcePath} both install to ${installPath} under ${root}; remove one of them`);
|
|
104
|
+
}
|
|
105
|
+
results.set(installPath, sourcePath);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Maps every file under `root` to its install path (tokens applied, dotfile
|
|
110
|
+
* name restored) -- the same derivation `planConflicts` uses -- keyed by
|
|
111
|
+
* install path, valued by absolute source path.
|
|
112
|
+
*
|
|
113
|
+
* @throws `Error` (no `cause`) naming the install path and both sources when
|
|
114
|
+
* two files map to the same install path (`_gitignore` beside `.gitignore`),
|
|
115
|
+
* rather than silently letting the later one win.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* const files = collectTemplateFiles("/repo/templates/core", { PROJECT_NAME: "acme" });
|
|
120
|
+
* files.get(".gitignore"); // "/repo/templates/core/_gitignore"
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function collectTemplateFiles(root, tokens) {
|
|
124
|
+
const results = new Map();
|
|
125
|
+
collectInto(root, root, tokens, results);
|
|
126
|
+
return results;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* `rmSync` options for a staging directory: recursive, tolerant of absence,
|
|
130
|
+
* and retried on a transient EBUSY/EPERM.
|
|
131
|
+
*/
|
|
132
|
+
const RM_DIR_OPTIONS = {
|
|
133
|
+
recursive: true,
|
|
134
|
+
force: true,
|
|
135
|
+
maxRetries: 3,
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* Removes `path` recursively; a failure only warns, naming the path, so it
|
|
139
|
+
* can never shadow the outcome it is cleaning up after.
|
|
140
|
+
*/
|
|
141
|
+
function removeBestEffort(path) {
|
|
142
|
+
try {
|
|
143
|
+
rmSync(path, RM_DIR_OPTIONS);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.warn(`warning: could not remove the temporary staging directory ${path} -- delete it by hand (${error instanceof Error ? error.message : String(error)})`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The swap's final rename and the restore of the parked previous staging
|
|
151
|
+
* both failed: the parked copy is the only one left. Its `errors` are
|
|
152
|
+
* `[swapError, restoreError]`.
|
|
153
|
+
*/
|
|
154
|
+
class ParkedStagingError extends AggregateError {
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* The standard staging failure: `.groundwork/` is incomplete and the CLI
|
|
158
|
+
* should be re-run. The message embeds the cause's own message so it stands
|
|
159
|
+
* alone; `formatErrorChain` skips the then-redundant `caused by:` line.
|
|
160
|
+
*/
|
|
161
|
+
function incompleteStagingError(noun, destDir, cause) {
|
|
162
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
163
|
+
return new Error(`staging the ${noun} into ${destDir} failed (${reason}), so .groundwork/ is incomplete -- fix the cause and re-run the CLI`, { cause });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Removes every entry of `groundworkDir` whose name starts with
|
|
167
|
+
* `.<dirName>-`, best effort (a removal failure only warns). Its purpose is
|
|
168
|
+
* reclaiming work directories a crashed earlier run left behind, but there
|
|
169
|
+
* is no PID or age check: any matching entry is removed, whatever made it,
|
|
170
|
+
* because `.groundwork/` is CLI-owned. So two concurrent runs against the
|
|
171
|
+
* same directory can delete each other's in-progress work directory; the
|
|
172
|
+
* run that loses fails with the generic {@link incompleteStagingError}
|
|
173
|
+
* ("`.groundwork/` is incomplete -- re-run the CLI"). Nothing else in
|
|
174
|
+
* `groundworkDir` is touched; a missing `groundworkDir` is a no-op. Failing
|
|
175
|
+
* to list `groundworkDir` at all throws {@link incompleteStagingError}.
|
|
176
|
+
*/
|
|
177
|
+
function removeStaleWorkDirs(target) {
|
|
178
|
+
const { groundworkDir, dirName, noun } = target;
|
|
179
|
+
if (!existsSync(groundworkDir)) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let names;
|
|
183
|
+
try {
|
|
184
|
+
names = readdirSync(groundworkDir);
|
|
185
|
+
}
|
|
186
|
+
catch (cause) {
|
|
187
|
+
throw incompleteStagingError(noun, join(groundworkDir, dirName), cause);
|
|
188
|
+
}
|
|
189
|
+
const prefix = `.${dirName}-`;
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
if (name.startsWith(prefix)) {
|
|
192
|
+
removeBestEffort(join(groundworkDir, name));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Moves `newDir` into place at `destDir`, parking any previous `destDir` at
|
|
198
|
+
* `parkedDir` first. If the final rename fails the parked copy is renamed
|
|
199
|
+
* back and the rename failure rethrown; if that restore fails too, throws a
|
|
200
|
+
* {@link ParkedStagingError} carrying both errors, naming `parkedDir`, and
|
|
201
|
+
* telling the user to re-run the CLI -- the staging is derived data, so the
|
|
202
|
+
* next run's stale-dir sweep removes the parked copy and regenerates
|
|
203
|
+
* `destDir`.
|
|
204
|
+
*/
|
|
205
|
+
function swapInto(target, newDir, destDir, parkedDir) {
|
|
206
|
+
const { noun } = target;
|
|
207
|
+
const was = target.plural ? "were" : "was";
|
|
208
|
+
const hadPrevious = existsSync(destDir);
|
|
209
|
+
if (hadPrevious) {
|
|
210
|
+
renameSync(destDir, parkedDir);
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
renameSync(newDir, destDir);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
if (!hadPrevious) {
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
renameSync(parkedDir, destDir);
|
|
221
|
+
}
|
|
222
|
+
catch (restoreError) {
|
|
223
|
+
throw new ParkedStagingError([error, restoreError], `moving the new ${noun} into ${destDir} failed and restoring the previous one failed too; the previous ${noun} ${was} parked at ${parkedDir} -- fix the cause and re-run the CLI: the staging is derived data, regenerated from the template, and the next run removes the parked copy`);
|
|
224
|
+
}
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Refuses a staging plan computed for a different `.groundwork/` than the
|
|
230
|
+
* one a stager was handed: the plan's scope-checked paths would otherwise
|
|
231
|
+
* not be the paths written. Directories are compared after `path.resolve`,
|
|
232
|
+
* so two spellings of one directory match. A caller defect, so a plain
|
|
233
|
+
* `Error` (never an `AssertionError`), thrown before anything is written.
|
|
234
|
+
*
|
|
235
|
+
* @throws `Error` naming both directories when they differ.
|
|
236
|
+
*
|
|
237
|
+
* @example
|
|
238
|
+
* ```ts
|
|
239
|
+
* assertPlanBuiltFor("stagePacks", plan.groundworkDir, groundworkDir);
|
|
240
|
+
* ```
|
|
241
|
+
*/
|
|
242
|
+
export function assertPlanBuiltFor(caller, planGroundworkDir, groundworkDir) {
|
|
243
|
+
const planned = resolve(planGroundworkDir);
|
|
244
|
+
const actual = resolve(groundworkDir);
|
|
245
|
+
if (planned !== actual) {
|
|
246
|
+
throw new Error(`${caller}: the plan was built for ${planned}, not ${actual} -- compute the plan for the same .groundwork/ directory it is staged into`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The first step of every staging run: refuses a symlinked `groundworkDir`
|
|
251
|
+
* or `<groundworkDir>/<dirName>` (before anything is deleted or written),
|
|
252
|
+
* then sweeps the `.<dirName>-*` work directories a crashed earlier run
|
|
253
|
+
* left. The sweep removes **every** entry of the CLI-owned `.groundwork/`
|
|
254
|
+
* whose name matches `.<dirName>-*`, whatever created it -- including a
|
|
255
|
+
* concurrent run's in-progress work directory, so two runs against the same
|
|
256
|
+
* directory can fail each other with the standard incomplete/re-run error;
|
|
257
|
+
* a failure to remove one only warns. Returns the staging directory's path.
|
|
258
|
+
*
|
|
259
|
+
* @throws `Error` naming the path when either directory is a symlink; the
|
|
260
|
+
* standard `.groundwork/ is incomplete -- re-run` `Error`, with `cause`, when
|
|
261
|
+
* `groundworkDir` cannot be listed for the sweep.
|
|
262
|
+
*
|
|
263
|
+
* @example
|
|
264
|
+
* ```ts
|
|
265
|
+
* const destDir = prepareStaging({ groundworkDir, dirName: "packs", noun: "packs", plural: true });
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function prepareStaging(target) {
|
|
269
|
+
const destDir = join(target.groundworkDir, target.dirName);
|
|
270
|
+
assertNotSymlink(target.groundworkDir);
|
|
271
|
+
assertNotSymlink(destDir);
|
|
272
|
+
removeStaleWorkDirs(target);
|
|
273
|
+
return destDir;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Removes the staging directory outright -- what a run with nothing to
|
|
277
|
+
* stage does, so no previous staging outlives it.
|
|
278
|
+
*
|
|
279
|
+
* @throws {@link incompleteStagingError}, with the removal failure as `cause`.
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* ```ts
|
|
283
|
+
* clearStaging({ groundworkDir, dirName: "packs", noun: "packs" });
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export function clearStaging(target) {
|
|
287
|
+
const destDir = join(target.groundworkDir, target.dirName);
|
|
288
|
+
try {
|
|
289
|
+
rmSync(destDir, RM_DIR_OPTIONS);
|
|
290
|
+
}
|
|
291
|
+
catch (cause) {
|
|
292
|
+
throw incompleteStagingError(target.noun, destDir, cause);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Writes `bytes` to `<rootDir>/<stagedName>` with the exclusive `wx` flag
|
|
297
|
+
* (creating parent directories) and returns their lowercase hex sha256.
|
|
298
|
+
* Re-asserts the CWE-22 containment invariant (docs/assurance-case.md)
|
|
299
|
+
* against the directory actually written to; the caller's plan must already
|
|
300
|
+
* have refused an escaping name.
|
|
301
|
+
*
|
|
302
|
+
* @throws `AssertionError` (message prefixed by `caller`) when the staged
|
|
303
|
+
* path escapes `rootDir`; any write error unchanged.
|
|
304
|
+
*
|
|
305
|
+
* @example
|
|
306
|
+
* ```ts
|
|
307
|
+
* const sha256 = writeStagedBytes("stagePacks", bytes, newDir, "pack.json.staged");
|
|
308
|
+
* ```
|
|
309
|
+
*/
|
|
310
|
+
export function writeStagedBytes(caller, bytes, rootDir, stagedName) {
|
|
311
|
+
const destPath = join(rootDir, stagedName);
|
|
312
|
+
assert.ok(isPathContained(destPath, rootDir), `${caller}: staged path ${resolve(destPath)} escapes ${resolve(rootDir)}`);
|
|
313
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
314
|
+
writeFileSync(destPath, bytes, { flag: "wx" });
|
|
315
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Runs `write` against a fresh `<groundworkDir>/.<dirName>-XXXXXX/<dirName>`
|
|
319
|
+
* directory and, only once it returns, swaps that directory over
|
|
320
|
+
* `<groundworkDir>/<dirName>` by rename -- so a failure part-way through
|
|
321
|
+
* leaves any previous staging intact, and a previous staging is replaced
|
|
322
|
+
* wholesale. Call {@link prepareStaging} first.
|
|
323
|
+
*
|
|
324
|
+
* - If the swap's final rename fails, the previous staging is renamed back.
|
|
325
|
+
* Only if that restore also fails is the staging directory left absent:
|
|
326
|
+
* the previous staging then survives, parked inside the work directory,
|
|
327
|
+
* which is deliberately not removed (a later run's stale-dir sweep does).
|
|
328
|
+
* - The work directory is otherwise always removed; a failure to remove it
|
|
329
|
+
* only warns, naming its path.
|
|
330
|
+
*
|
|
331
|
+
* @throws {@link ParkedStagingError} when both renames fail; an
|
|
332
|
+
* `AssertionError` unwrapped (a broken invariant is a bug, not something a
|
|
333
|
+
* re-run fixes); otherwise {@link incompleteStagingError} with `cause`.
|
|
334
|
+
*
|
|
335
|
+
* @example
|
|
336
|
+
* ```ts
|
|
337
|
+
* const files = stageAtomically(target, (newDir) =>
|
|
338
|
+
* [writeStagedBytes("stagePacks", bytes, newDir, "a.txt.staged")],
|
|
339
|
+
* );
|
|
340
|
+
* ```
|
|
341
|
+
*/
|
|
342
|
+
export function stageAtomically(target, write) {
|
|
343
|
+
const destDir = join(target.groundworkDir, target.dirName);
|
|
344
|
+
let workDir;
|
|
345
|
+
// Set only when the previous staging is parked inside workDir and could
|
|
346
|
+
// not be restored: workDir then holds its only copy and must survive.
|
|
347
|
+
let keepWorkDir = false;
|
|
348
|
+
try {
|
|
349
|
+
mkdirSync(target.groundworkDir, { recursive: true });
|
|
350
|
+
workDir = mkdtempSync(join(target.groundworkDir, `.${target.dirName}-`));
|
|
351
|
+
const newDir = join(workDir, target.dirName);
|
|
352
|
+
mkdirSync(newDir);
|
|
353
|
+
const result = write(newDir);
|
|
354
|
+
swapInto(target, newDir, destDir, join(workDir, "previous"));
|
|
355
|
+
return result;
|
|
356
|
+
}
|
|
357
|
+
catch (cause) {
|
|
358
|
+
if (cause instanceof ParkedStagingError) {
|
|
359
|
+
keepWorkDir = true;
|
|
360
|
+
throw cause;
|
|
361
|
+
}
|
|
362
|
+
if (cause instanceof assert.AssertionError) {
|
|
363
|
+
// A broken CWE-22 invariant is a bug in the stager, not a transient
|
|
364
|
+
// failure a re-run could fix: surface it as itself.
|
|
365
|
+
throw cause;
|
|
366
|
+
}
|
|
367
|
+
throw incompleteStagingError(target.noun, destDir, cause);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
if (workDir !== undefined && !keepWorkDir) {
|
|
371
|
+
removeBestEffort(workDir);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=staging.js.map
|
package/dist/survey/fs-walk.d.ts
CHANGED
|
@@ -8,7 +8,39 @@ export interface WalkEntry {
|
|
|
8
8
|
/**
|
|
9
9
|
* Recursively lists `root`, skipping known dependency/build directories and
|
|
10
10
|
* stopping once a descendant is more than `maxDepth` directories below
|
|
11
|
-
* `root`.
|
|
11
|
+
* `root`. A missing directory (`ENOENT`/`ENOTDIR`, the same absent set
|
|
12
|
+
* `guardedExists` uses) is skipped silently. An unreadable or unresolvable
|
|
13
|
+
* one (`EACCES`/`EPERM`, or a symlink loop's `ELOOP`) is skipped too, but
|
|
14
|
+
* recorded in `undetermined`. Any other listing
|
|
15
|
+
* failure (`EIO`, `EMFILE`, ...) throws an `Error` naming the directory, with
|
|
16
|
+
* the original as `cause`. For the survey collectors; the graders use
|
|
17
|
+
* {@link walkBoundedForGrading} instead.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* const undetermined: string[] = [];
|
|
22
|
+
* const markdown = walkBounded("/path/to/project", 2, undetermined).filter(
|
|
23
|
+
* (entry) => !entry.isDirectory && entry.relPath.endsWith(".md"),
|
|
24
|
+
* );
|
|
25
|
+
* ```
|
|
12
26
|
*/
|
|
13
|
-
export declare function walkBounded(root: string, maxDepth: number): WalkEntry[];
|
|
27
|
+
export declare function walkBounded(root: string, maxDepth: number, undetermined: string[]): WalkEntry[];
|
|
28
|
+
/**
|
|
29
|
+
* The same bounded listing as {@link walkBounded}, but a directory whose
|
|
30
|
+
* listing fails for ANY reason is skipped silently, never thrown. For the
|
|
31
|
+
* harness and toolchain graders (`harness/grade.ts`, `toolchain/grade.ts`)
|
|
32
|
+
* only: a grader never throws, and its emitted `.mjs` twins
|
|
33
|
+
* (`templates/core/bin/lib/{harness,toolchain}-rules.mjs`) swallow every
|
|
34
|
+
* listing failure in their own `walkBounded` -- this keeps both sides' grades
|
|
35
|
+
* identical for the same tree. The survey keeps {@link walkBounded}'s errno
|
|
36
|
+
* discrimination.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const files = walkBoundedForGrading("/path/to/project", 4).filter(
|
|
41
|
+
* (entry) => !entry.isDirectory,
|
|
42
|
+
* );
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function walkBoundedForGrading(root: string, maxDepth: number): WalkEntry[];
|
|
14
46
|
//# sourceMappingURL=fs-walk.d.ts.map
|
package/dist/survey/fs-walk.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
1
3
|
/**
|
|
2
4
|
* A bounded directory walk shared by every survey collector. Adopt mode may
|
|
3
5
|
* run against an arbitrarily large pre-existing repository (dependency
|
|
@@ -7,6 +9,7 @@
|
|
|
7
9
|
*/
|
|
8
10
|
import { readdirSync } from "node:fs";
|
|
9
11
|
import { join, relative } from "node:path";
|
|
12
|
+
import { isAbsentError, readFailure, unreadableNote, unresolvableCode, } from "./internal/read-guard.js";
|
|
10
13
|
const SKIP_DIR_NAMES = new Set([
|
|
11
14
|
"node_modules",
|
|
12
15
|
".git",
|
|
@@ -20,12 +23,17 @@ const SKIP_DIR_NAMES = new Set([
|
|
|
20
23
|
"out",
|
|
21
24
|
".nx",
|
|
22
25
|
]);
|
|
26
|
+
// Claude Code creates git worktrees at `.claude/worktrees/<name>/` -- each a
|
|
27
|
+
// full second checkout that must not be walked twice. Matched as an exact
|
|
28
|
+
// path relative to the walk root, never by bare name: a directory literally
|
|
29
|
+
// named `worktrees` elsewhere (`src/worktrees/`, `.claude/skills/worktrees/`)
|
|
30
|
+
// is real project content and must stay visible to every survey.
|
|
31
|
+
const SKIP_REL_DIR_PATHS = new Set([".claude/worktrees"]);
|
|
23
32
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `root`. Missing or unreadable directories are skipped, not thrown.
|
|
33
|
+
* The shared bounded walk. `onListFailure` decides what a failed directory
|
|
34
|
+
* listing means: it returns normally to skip the directory, or throws.
|
|
27
35
|
*/
|
|
28
|
-
|
|
36
|
+
function walk(root, maxDepth, onListFailure) {
|
|
29
37
|
const results = [];
|
|
30
38
|
const visit = (dir, depth) => {
|
|
31
39
|
if (depth > maxDepth) {
|
|
@@ -35,7 +43,8 @@ export function walkBounded(root, maxDepth) {
|
|
|
35
43
|
try {
|
|
36
44
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
37
45
|
}
|
|
38
|
-
catch {
|
|
46
|
+
catch (error) {
|
|
47
|
+
onListFailure(dir, error);
|
|
39
48
|
return;
|
|
40
49
|
}
|
|
41
50
|
for (const entry of entries) {
|
|
@@ -44,6 +53,9 @@ export function walkBounded(root, maxDepth) {
|
|
|
44
53
|
}
|
|
45
54
|
const absPath = join(dir, entry.name);
|
|
46
55
|
const relPath = relative(root, absPath).split("\\").join("/");
|
|
56
|
+
if (entry.isDirectory() && SKIP_REL_DIR_PATHS.has(relPath)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
47
59
|
results.push({
|
|
48
60
|
path: absPath,
|
|
49
61
|
relPath,
|
|
@@ -57,4 +69,57 @@ export function walkBounded(root, maxDepth) {
|
|
|
57
69
|
visit(root, 0);
|
|
58
70
|
return results;
|
|
59
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Recursively lists `root`, skipping known dependency/build directories and
|
|
74
|
+
* stopping once a descendant is more than `maxDepth` directories below
|
|
75
|
+
* `root`. A missing directory (`ENOENT`/`ENOTDIR`, the same absent set
|
|
76
|
+
* `guardedExists` uses) is skipped silently. An unreadable or unresolvable
|
|
77
|
+
* one (`EACCES`/`EPERM`, or a symlink loop's `ELOOP`) is skipped too, but
|
|
78
|
+
* recorded in `undetermined`. Any other listing
|
|
79
|
+
* failure (`EIO`, `EMFILE`, ...) throws an `Error` naming the directory, with
|
|
80
|
+
* the original as `cause`. For the survey collectors; the graders use
|
|
81
|
+
* {@link walkBoundedForGrading} instead.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const undetermined: string[] = [];
|
|
86
|
+
* const markdown = walkBounded("/path/to/project", 2, undetermined).filter(
|
|
87
|
+
* (entry) => !entry.isDirectory && entry.relPath.endsWith(".md"),
|
|
88
|
+
* );
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function walkBounded(root, maxDepth, undetermined) {
|
|
92
|
+
return walk(root, maxDepth, (dir, error) => {
|
|
93
|
+
const code = unresolvableCode(error);
|
|
94
|
+
if (code !== undefined) {
|
|
95
|
+
undetermined.push(unreadableNote(dir, code));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (isAbsentError(error))
|
|
99
|
+
return;
|
|
100
|
+
throw readFailure(dir, error);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The same bounded listing as {@link walkBounded}, but a directory whose
|
|
105
|
+
* listing fails for ANY reason is skipped silently, never thrown. For the
|
|
106
|
+
* harness and toolchain graders (`harness/grade.ts`, `toolchain/grade.ts`)
|
|
107
|
+
* only: a grader never throws, and its emitted `.mjs` twins
|
|
108
|
+
* (`templates/core/bin/lib/{harness,toolchain}-rules.mjs`) swallow every
|
|
109
|
+
* listing failure in their own `walkBounded` -- this keeps both sides' grades
|
|
110
|
+
* identical for the same tree. The survey keeps {@link walkBounded}'s errno
|
|
111
|
+
* discrimination.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* const files = walkBoundedForGrading("/path/to/project", 4).filter(
|
|
116
|
+
* (entry) => !entry.isDirectory,
|
|
117
|
+
* );
|
|
118
|
+
* ```
|
|
119
|
+
*/
|
|
120
|
+
export function walkBoundedForGrading(root, maxDepth) {
|
|
121
|
+
return walk(root, maxDepth, () => {
|
|
122
|
+
// Deliberately swallowed: parity with the emitted twins' bare `catch`.
|
|
123
|
+
});
|
|
124
|
+
}
|
|
60
125
|
//# sourceMappingURL=fs-walk.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why a path `probePath` reported `absent` is not really absent, or
|
|
3
|
+
* `undefined` when nothing is there at all. A symlink at the path itself is
|
|
4
|
+
* named as dangling whenever `stat` through it failed -- its target missing
|
|
5
|
+
* (`ENOENT`) or resolving through a regular file (`ENOTDIR`) alike, since
|
|
6
|
+
* `lstat` finding a link is all this checks. A dangling symlink at an
|
|
7
|
+
* ancestor below `root` is named the same way, but only for a missing target
|
|
8
|
+
* (`ENOENT`); an ancestor link resolving through a regular file surfaces as
|
|
9
|
+
* `ENOTDIR` instead, named by the nearest non-directory ancestor that can be
|
|
10
|
+
* established, else the path itself. A regular file blocking an
|
|
11
|
+
* ancestor is named with `ENOTDIR`; an `lstat` that succeeds on a
|
|
12
|
+
* non-symlink right after `stat` said absent means the tree changed during
|
|
13
|
+
* the survey, and the path is recorded unreadable (`ENOENT`). Any other
|
|
14
|
+
* `lstat` errno throws a `SurveyReadError` naming the path, with the
|
|
15
|
+
* original as `cause`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const probe = probePath(targetPath);
|
|
20
|
+
* if (probe.kind === "absent") {
|
|
21
|
+
* const note = blockedAbsentNote(targetPath, targetDir);
|
|
22
|
+
* if (note !== undefined) undetermined.push(note);
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export declare function blockedAbsentNote(path: string, root: string): string | undefined;
|
|
27
|
+
//# sourceMappingURL=blocked-path.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright the m3l-groundwork contributors
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
/**
|
|
4
|
+
* Tells a genuinely absent path apart from one `probePath` merely folded into
|
|
5
|
+
* "absent". `probePath` answers `absent` for `ENOENT`/`ENOTDIR` -- correct for
|
|
6
|
+
* the survey's own exists-probes -- but that also covers a dangling symlink at
|
|
7
|
+
* the path or at one of its ancestors (`ENOENT`) and a regular file where an
|
|
8
|
+
* ancestor directory should be (`ENOTDIR`). A plan (`conflicts.ts`) or a
|
|
9
|
+
* wiring observation (`packs.ts`) must never read either as "nothing there":
|
|
10
|
+
* both are something in the project's tree a write would have to go through.
|
|
11
|
+
* Shared so the two modules record the same note for the same path.
|
|
12
|
+
*/
|
|
13
|
+
import { lstatSync, statSync } from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { errnoCode, readFailure, unreadableNote } from "./read-guard.js";
|
|
16
|
+
// "its target does not exist" reads loosely for a link at the path itself
|
|
17
|
+
// whose target resolves through a regular file (`ENOTDIR`) -- that target
|
|
18
|
+
// does not exist as a reachable path either. Wording kept as-is: tests
|
|
19
|
+
// match on "dangling symlink".
|
|
20
|
+
function danglingNote(path) {
|
|
21
|
+
return `${path} is a dangling symlink -- its target does not exist, so its contents are not in this survey`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The nearest ancestor of `path`, walking up no further than `root`, that
|
|
25
|
+
* exists and is not a directory -- the component that made a `stat` of
|
|
26
|
+
* `path` fail with `ENOTDIR` -- or `undefined` if none can be established
|
|
27
|
+
* (the tree changed underneath, or the ancestor cannot itself be stat'd).
|
|
28
|
+
*/
|
|
29
|
+
function blockedAncestor(path, root) {
|
|
30
|
+
let current = dirname(path);
|
|
31
|
+
for (;;) {
|
|
32
|
+
try {
|
|
33
|
+
if (!statSync(current).isDirectory())
|
|
34
|
+
return current;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// This component is itself absent or unreachable: it is not the file
|
|
38
|
+
// blocking the path, so keep walking up. Best effort -- the caller
|
|
39
|
+
// still records the entry with `ENOTDIR` on the path itself.
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(current);
|
|
42
|
+
if (current === root || parent === current)
|
|
43
|
+
return undefined;
|
|
44
|
+
current = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The nearest strict ancestor of `path`, below `root`, that is a symlink
|
|
49
|
+
* whose target does not exist -- the component that made an `lstat` of
|
|
50
|
+
* `path` fail with `ENOENT` -- or `undefined` when every ancestor that
|
|
51
|
+
* exists resolves (the path is genuinely absent).
|
|
52
|
+
*/
|
|
53
|
+
function danglingAncestor(path, root) {
|
|
54
|
+
for (let current = dirname(path); current !== root && dirname(current) !== current; current = dirname(current)) {
|
|
55
|
+
if (isDanglingSymlink(current))
|
|
56
|
+
return current;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether `path` itself is a symlink whose target does not exist (`lstat`
|
|
62
|
+
* finds a link, `stat` through it fails `ENOENT`). Best effort: the caller
|
|
63
|
+
* only walks here after an `lstat` of a descendant already traversed every
|
|
64
|
+
* existing ancestor, so an ancestor this cannot classify is not the dangling
|
|
65
|
+
* one and answers `false`.
|
|
66
|
+
*/
|
|
67
|
+
function isDanglingSymlink(path) {
|
|
68
|
+
try {
|
|
69
|
+
if (!lstatSync(path).isSymbolicLink())
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Absent or unclassifiable at the link itself: not a dangling symlink.
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
statSync(path);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return errnoCode(error) === "ENOENT";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Why a path `probePath` reported `absent` is not really absent, or
|
|
86
|
+
* `undefined` when nothing is there at all. A symlink at the path itself is
|
|
87
|
+
* named as dangling whenever `stat` through it failed -- its target missing
|
|
88
|
+
* (`ENOENT`) or resolving through a regular file (`ENOTDIR`) alike, since
|
|
89
|
+
* `lstat` finding a link is all this checks. A dangling symlink at an
|
|
90
|
+
* ancestor below `root` is named the same way, but only for a missing target
|
|
91
|
+
* (`ENOENT`); an ancestor link resolving through a regular file surfaces as
|
|
92
|
+
* `ENOTDIR` instead, named by the nearest non-directory ancestor that can be
|
|
93
|
+
* established, else the path itself. A regular file blocking an
|
|
94
|
+
* ancestor is named with `ENOTDIR`; an `lstat` that succeeds on a
|
|
95
|
+
* non-symlink right after `stat` said absent means the tree changed during
|
|
96
|
+
* the survey, and the path is recorded unreadable (`ENOENT`). Any other
|
|
97
|
+
* `lstat` errno throws a `SurveyReadError` naming the path, with the
|
|
98
|
+
* original as `cause`.
|
|
99
|
+
*
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* const probe = probePath(targetPath);
|
|
103
|
+
* if (probe.kind === "absent") {
|
|
104
|
+
* const note = blockedAbsentNote(targetPath, targetDir);
|
|
105
|
+
* if (note !== undefined) undetermined.push(note);
|
|
106
|
+
* }
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export function blockedAbsentNote(path, root) {
|
|
110
|
+
let isSymlink;
|
|
111
|
+
try {
|
|
112
|
+
isSymlink = lstatSync(path).isSymbolicLink();
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const code = errnoCode(error);
|
|
116
|
+
if (code === "ENOTDIR") {
|
|
117
|
+
return unreadableNote(blockedAncestor(path, root) ?? path, "ENOTDIR");
|
|
118
|
+
}
|
|
119
|
+
if (code !== "ENOENT")
|
|
120
|
+
throw readFailure(path, error);
|
|
121
|
+
const ancestor = danglingAncestor(path, root);
|
|
122
|
+
return ancestor === undefined ? undefined : danglingNote(ancestor);
|
|
123
|
+
}
|
|
124
|
+
// `lstat` succeeded after `stat` said absent. A symlink means `stat` could
|
|
125
|
+
// not resolve through it (`ENOENT` or `ENOTDIR`): dangling. A non-symlink
|
|
126
|
+
// means the tree changed during the survey: present, contents unknown.
|
|
127
|
+
return isSymlink ? danglingNote(path) : unreadableNote(path, "ENOENT");
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=blocked-path.js.map
|