sortie-dogs 0.3.14 → 0.3.16
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 +7 -5
- package/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/plugin/continuation.d.ts +1 -0
- package/dist/plugin/continuation.js +58 -0
- package/dist/plugin/gate.d.ts +10 -1
- package/dist/plugin/gate.js +121 -4
- package/dist/plugin/index.js +178 -26
- package/dist/runtime-assets.d.ts +8 -8
- package/dist/runtime-assets.js +84 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|

|
|
13
13
|
|
|
14
14
|
Sortie-dogs is an opt-in OpenCode orchestration plugin. It turns a task into a
|
|
15
|
-
scoped plan, parallel investigation,
|
|
15
|
+
scoped plan, parallel investigation, bounded independent implementation, canonical
|
|
16
16
|
validation, and evidence-backed completion—while preserving standard OpenCode
|
|
17
17
|
agents and settings.
|
|
18
18
|
|
|
19
19
|
Requirements: Node.js 22.6 or newer, npm, and OpenCode.
|
|
20
20
|
|
|
21
|
-
Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md)
|
|
21
|
+
Guides: [日本語](docs/guide-ja.md) · [简体中文](docs/guide-zh-CN.md) · [CLI testing](docs/cli-testing.md)
|
|
22
22
|
|
|
23
|
-
Release: [v0.3.
|
|
23
|
+
Release: [v0.3.16](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.3.16)
|
|
24
24
|
|
|
25
25
|
## Quick start
|
|
26
26
|
|
|
@@ -207,8 +207,10 @@ global file for durable global settings.
|
|
|
207
207
|
exactly three bounded scouts before implementation begins.
|
|
208
208
|
- **Writes stay inside the assignment.** Exact source or operation manifests
|
|
209
209
|
gate edits and handoffs.
|
|
210
|
-
- **
|
|
211
|
-
|
|
210
|
+
- **Safe implementation fan-out.** Independent units may use two or three workers
|
|
211
|
+
with non-overlapping write manifests; dependent or same-path work stays on one worker.
|
|
212
|
+
- **Runtime overlap protection.** Active equal or ancestor write scopes are rejected
|
|
213
|
+
before mutation, and full validation waits until every parallel unit joins.
|
|
212
214
|
- **Evidence before completion.** Canonical validation, risk-based review, and
|
|
213
215
|
terminal evidence gate coordinator-owned completion and commits.
|
|
214
216
|
- **Long work can recover.** Restart recovery and bounded compaction continue
|
package/dist/asset-version.d.ts
CHANGED
|
@@ -2,5 +2,5 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export declare const RUNTIME_ASSET_VERSION = "0.3.4-
|
|
5
|
+
export declare const RUNTIME_ASSET_VERSION = "0.3.4-card38";
|
|
6
6
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
* Version of the installable runtime assets. Kept in its own module so the plugin can compare an
|
|
3
3
|
* installed project marker without importing every asset body.
|
|
4
4
|
*/
|
|
5
|
-
export const RUNTIME_ASSET_VERSION = "0.3.4-
|
|
5
|
+
export const RUNTIME_ASSET_VERSION = "0.3.4-card38";
|
|
@@ -20,6 +20,7 @@ export declare const CONTINUATION_MARKER = "<!-- SORTIE_CONTINUE -->";
|
|
|
20
20
|
export declare const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
|
|
21
21
|
/** First token of the synthetic resume prompt, so the coordinator can recognize its own resume. */
|
|
22
22
|
export declare const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
|
|
23
|
+
export declare const STEP_CONTINUE_PREFIX = "SORTIE_STEP_CONTINUE";
|
|
23
24
|
/** First line the rollover summary must emit, mirroring the batch target of three attempts. */
|
|
24
25
|
export declare const ROLLOVER_TOKEN = "SORTIE_ROLLOVER_COMPACTED";
|
|
25
26
|
export declare const DEFAULT_MAX_AUTO_CONTINUES = 2;
|
|
@@ -20,6 +20,7 @@ export const CONTINUATION_MARKER = "<!-- SORTIE_CONTINUE -->";
|
|
|
20
20
|
export const ROLLOVER_MARKER = "<!-- SORTIE_COMPACT -->";
|
|
21
21
|
/** First token of the synthetic resume prompt, so the coordinator can recognize its own resume. */
|
|
22
22
|
export const AUTO_CONTINUE_PREFIX = "SORTIE_AUTO_CONTINUE";
|
|
23
|
+
export const STEP_CONTINUE_PREFIX = "SORTIE_STEP_CONTINUE";
|
|
23
24
|
const TOOL_REQUESTED_REPORT = "Tool-requested Sortie rollover. Preserve task identity, both manifests, validation history, " +
|
|
24
25
|
"batch counters, blocker state, and the exact next action from the latest messages.";
|
|
25
26
|
/** First line the rollover summary must emit, mirroring the batch target of three attempts. */
|
|
@@ -32,6 +33,7 @@ export const DEFAULT_MAX_AUTO_CONTINUES = 2;
|
|
|
32
33
|
*/
|
|
33
34
|
export const STEP_EXHAUSTED_PATTERN = /(?:最大step(?:s|数)?(?:に)?到達|step(?:s)?\s*(?:limit|budget)\s*(?:reached|exhausted)|maximum\s+steps?\s+reached)[\s\S]{0,2500}(?:残作業|未完了|未完|次action|next\s+action|remaining\s+work)/iu;
|
|
34
35
|
const MAX_TRACKED_SESSIONS = 256;
|
|
36
|
+
const MAX_STEP_CONTINUES_PER_TURN = 2;
|
|
35
37
|
const DEFAULT_TIMINGS = {
|
|
36
38
|
/** Compaction is expensive; one rollover per minute per session is the canonical ceiling. */
|
|
37
39
|
cooldownMilliseconds: 60_000,
|
|
@@ -198,6 +200,8 @@ export function createContinuationHooks(client, directory, policySource, timings
|
|
|
198
200
|
ownsHostContinuation: false,
|
|
199
201
|
textCompleting: false,
|
|
200
202
|
directUsed: false,
|
|
203
|
+
stepContinueCount: 0,
|
|
204
|
+
stepContinueIssuing: false,
|
|
201
205
|
touched: Date.now(),
|
|
202
206
|
};
|
|
203
207
|
sessions.set(sessionID, created);
|
|
@@ -270,6 +274,47 @@ export function createContinuationHooks(client, directory, policySource, timings
|
|
|
270
274
|
if (!promptCallSucceeded(resumed))
|
|
271
275
|
throw new Error("resume request rejected");
|
|
272
276
|
}
|
|
277
|
+
function nonTerminalProgress(text) {
|
|
278
|
+
if (text.startsWith(STEP_CONTINUE_PREFIX) || text.startsWith(AUTO_CONTINUE_PREFIX))
|
|
279
|
+
return false;
|
|
280
|
+
if (/\bstatus:\s*(?:DONE|BLOCKED|NEED_DECISION)\b/iu.test(text))
|
|
281
|
+
return false;
|
|
282
|
+
return /➡️\s*(?:次action|next_action)\s*:\s*\S/iu.test(text);
|
|
283
|
+
}
|
|
284
|
+
async function issueStepContinue(sessionID, state, report, agent) {
|
|
285
|
+
const resume = client?.session?.promptAsync;
|
|
286
|
+
const active = policy();
|
|
287
|
+
if (resume === undefined || !active.enabled || active.agent !== agent || state.stepContinueIssuing ||
|
|
288
|
+
state.stepContinueCount >= MAX_STEP_CONTINUES_PER_TURN)
|
|
289
|
+
return;
|
|
290
|
+
state.stepContinueIssuing = true;
|
|
291
|
+
state.stepContinueCount += 1;
|
|
292
|
+
state.latestCoordinatorReport = undefined;
|
|
293
|
+
try {
|
|
294
|
+
const resumed = await resume.call(client.session, {
|
|
295
|
+
path: { id: sessionID },
|
|
296
|
+
query: { directory },
|
|
297
|
+
body: {
|
|
298
|
+
agent,
|
|
299
|
+
parts: [{
|
|
300
|
+
type: "text",
|
|
301
|
+
synthetic: true,
|
|
302
|
+
text: `${STEP_CONTINUE_PREFIX}\n直前出力は非terminal進捗。繰り返さず、同じcandidateのnext_actionを今すぐtoolで実行する。\n${report}`,
|
|
303
|
+
}],
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
if (!promptCallSucceeded(resumed))
|
|
307
|
+
throw new Error("step continuation request rejected");
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
state.stepContinueCount -= 1;
|
|
311
|
+
state.latestCoordinatorReport = report;
|
|
312
|
+
console.error("[sortie-continuation] step resume failed", sessionID, error);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
state.stepContinueIssuing = false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
273
318
|
/**
|
|
274
319
|
* Single resume arbiter. Host events and timers may arrive in any order, but none of them may call
|
|
275
320
|
* promptAsync directly. The epoch lock makes concurrent compacted/text/idle signals idempotent.
|
|
@@ -680,6 +725,7 @@ export function createContinuationHooks(client, directory, policySource, timings
|
|
|
680
725
|
state.model = { providerID: model.providerID, modelID: model.modelID };
|
|
681
726
|
state.turnRevision += 1;
|
|
682
727
|
if (!synthetic) {
|
|
728
|
+
state.stepContinueCount = 0;
|
|
683
729
|
state.ownsHostContinuation = false;
|
|
684
730
|
state.limitCompacted = false;
|
|
685
731
|
}
|
|
@@ -704,6 +750,18 @@ export function createContinuationHooks(client, directory, policySource, timings
|
|
|
704
750
|
}
|
|
705
751
|
if (state?.pendingRollover === true) {
|
|
706
752
|
await runRollover(sessionID);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (state !== undefined && !state.stepContinueIssuing &&
|
|
756
|
+
state.latestCoordinatorReport !== undefined &&
|
|
757
|
+
nonTerminalProgress(state.latestCoordinatorReport)) {
|
|
758
|
+
const report = state.latestCoordinatorReport;
|
|
759
|
+
const revision = state.turnRevision;
|
|
760
|
+
const identity = await readIdentity(sessionID);
|
|
761
|
+
const active = policy();
|
|
762
|
+
if (active.enabled && identity !== undefined && identity.agent === active.agent && !nonEmpty(identity.parentID) &&
|
|
763
|
+
state.turnRevision === revision && state.latestCoordinatorReport === report)
|
|
764
|
+
await issueStepContinue(sessionID, state, report, active.agent);
|
|
707
765
|
}
|
|
708
766
|
}
|
|
709
767
|
}
|
package/dist/plugin/gate.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OperationManifest } from "../core/types.js";
|
|
1
2
|
export interface ToolExecuteBeforeInput {
|
|
2
3
|
tool: string;
|
|
3
4
|
sessionID: string;
|
|
@@ -6,7 +7,7 @@ export interface ToolExecuteBeforeInput {
|
|
|
6
7
|
export interface ToolExecuteBeforeOutput {
|
|
7
8
|
args: unknown;
|
|
8
9
|
}
|
|
9
|
-
export type WriteDenialReason = "manifest-unavailable" | "session-expired" | "unclassified-command" | "path-required" | "project-boundary" | "manifest-scope" | "repeated-denial";
|
|
10
|
+
export type WriteDenialReason = "manifest-unavailable" | "session-expired" | "unclassified-command" | "path-required" | "project-boundary" | "manifest-scope" | "parallel-git-mutation" | "parallel-remote-mutation" | "parallel-validation" | "session-released" | "repeated-denial";
|
|
10
11
|
export declare class WriteDeniedError extends Error {
|
|
11
12
|
readonly reason: WriteDenialReason;
|
|
12
13
|
constructor(reason: WriteDenialReason, path: string, options?: ErrorOptions);
|
|
@@ -27,6 +28,8 @@ interface Extraction {
|
|
|
27
28
|
ambiguous: boolean;
|
|
28
29
|
paths: string[];
|
|
29
30
|
gitCommit?: boolean;
|
|
31
|
+
gitMutation?: boolean;
|
|
32
|
+
remoteMutation?: boolean;
|
|
30
33
|
issue?: CommandIssue;
|
|
31
34
|
}
|
|
32
35
|
interface CommandIssue {
|
|
@@ -42,6 +45,8 @@ export declare function resolveProjectRoot(input: {
|
|
|
42
45
|
export declare function describeUnclassifiedCommand(tool: string, args: unknown): string | undefined;
|
|
43
46
|
/** Extract known write destinations; unknown shell executables fail closed as ambiguous. */
|
|
44
47
|
export declare function extractWritePaths(tool: string, args: unknown): Extraction;
|
|
48
|
+
export declare function isGitMutation(tool: string, args: unknown): boolean;
|
|
49
|
+
export declare function isRemoteMutation(tool: string, args: unknown): boolean;
|
|
45
50
|
/**
|
|
46
51
|
* Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
|
|
47
52
|
* same command written with different spacing compares equal.
|
|
@@ -49,6 +54,10 @@ export declare function extractWritePaths(tool: string, args: unknown): Extracti
|
|
|
49
54
|
export declare function normalizeCommand(command: string): string;
|
|
50
55
|
/** Unbound sessions may invoke only tools whose complete input is known to be read-only. */
|
|
51
56
|
export declare function isKnownReadOnlyTool(tool: string, args: unknown, additionalReadOnlyTools?: ReadonlySet<string>): boolean;
|
|
57
|
+
/** Canonical scopes let sibling worker bindings reject equal or ancestor ownership. */
|
|
58
|
+
export declare function canonicalManifestWriteScopes(project: ProjectPaths, manifest: OperationManifest): Promise<readonly string[]>;
|
|
59
|
+
export declare function canonicalManifestReadScopes(project: ProjectPaths, manifest: OperationManifest): Promise<readonly string[]>;
|
|
60
|
+
export declare function writeScopesOverlap(left: readonly string[], right: readonly string[]): boolean;
|
|
52
61
|
export declare function createProjectPaths(rootCandidate: string): Promise<ProjectPaths>;
|
|
53
62
|
export declare function createWriteGate(project: ProjectPaths, value: unknown): Promise<WriteGate>;
|
|
54
63
|
export {};
|
package/dist/plugin/gate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { lstat, realpath } from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { RelativePathError, normalizeManifestPath, normalizeRelativePath } from "../core/path.js";
|
|
6
6
|
import { validateOperationManifestSchema } from "../core/validate-schema.js";
|
|
@@ -14,6 +14,10 @@ export class WriteDeniedError extends Error {
|
|
|
14
14
|
"path-required": "write path must be explicit.",
|
|
15
15
|
"project-boundary": "project-root-relative path required.",
|
|
16
16
|
"manifest-scope": "operation manifest write scope.",
|
|
17
|
+
"parallel-git-mutation": "parallel implementation workers cannot mutate shared Git state.",
|
|
18
|
+
"parallel-remote-mutation": "parallel implementation workers cannot mutate shared remote state.",
|
|
19
|
+
"parallel-validation": "parallel implementation validation must run after the worker join.",
|
|
20
|
+
"session-released": "released session must re-read only its handoff and bind before further work.",
|
|
17
21
|
"repeated-denial": "same command and denial reason already denied in this session; retry blocked.",
|
|
18
22
|
};
|
|
19
23
|
super(`Write denied for "${safePath(path)}": ${messages[reason]}`, options);
|
|
@@ -131,6 +135,14 @@ function isRemoteOnlyGitHubCommand(tokens) {
|
|
|
131
135
|
const command = tokens[1]?.toLowerCase();
|
|
132
136
|
return command === "project" || (command === "api" && tokens[2]?.toLowerCase() === "graphql");
|
|
133
137
|
}
|
|
138
|
+
function isRemoteGitHubMutation(tokens) {
|
|
139
|
+
const command = tokens[1]?.toLowerCase();
|
|
140
|
+
if (command === "project") {
|
|
141
|
+
return !new Set(["field-list", "item-list", "list", "view"]).has(tokens[2]?.toLowerCase() ?? "");
|
|
142
|
+
}
|
|
143
|
+
// GraphQL transport can hide mutations in variables, files, or stdin; parallel workers fail closed.
|
|
144
|
+
return command === "api" && tokens[2]?.toLowerCase() === "graphql";
|
|
145
|
+
}
|
|
134
146
|
function isReadOnlyGitCommand(tokens) {
|
|
135
147
|
const command = tokens[1]?.toLowerCase() ?? "";
|
|
136
148
|
return READ_ONLY_GIT_COMMANDS.has(command) ||
|
|
@@ -226,9 +238,14 @@ function shellSegments(command, dialect) {
|
|
|
226
238
|
for (let index = 0; index < masked.length; index += 1) {
|
|
227
239
|
const character = masked[index];
|
|
228
240
|
const pair = masked.slice(index, index + 2);
|
|
241
|
+
const segmentBefore = masked.slice(start, index).trim();
|
|
242
|
+
const hasCommandBefore = segmentBefore.length > 0;
|
|
243
|
+
const powershellCallOperator = dialect === "powershell" &&
|
|
244
|
+
(segmentBefore.length === 0 || segmentBefore.endsWith("="));
|
|
229
245
|
const separator = pair === "&&" || pair === "||" ? 2
|
|
230
246
|
: character === ";" || character === "\n" || character === "\r" ||
|
|
231
|
-
(character === "|" && command[index - 1] !== ">")
|
|
247
|
+
(character === "|" && command[index - 1] !== ">") ||
|
|
248
|
+
(character === "&" && hasCommandBefore && command[index - 1] !== ">" && !powershellCallOperator) ? 1 : 0;
|
|
232
249
|
if (separator === 0)
|
|
233
250
|
continue;
|
|
234
251
|
segments.push(command.slice(start, index));
|
|
@@ -252,6 +269,8 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
252
269
|
let applies = false;
|
|
253
270
|
let ambiguous = false;
|
|
254
271
|
let gitCommit = false;
|
|
272
|
+
let gitMutation = false;
|
|
273
|
+
let remoteMutation = false;
|
|
255
274
|
let issue;
|
|
256
275
|
const redirection = /(?<![<>=!])(?:&|\d*)?(?:>>|>\||>)(?![=])/gu;
|
|
257
276
|
const redirectionTarget = /^\s*("(?:\\.|[^"])*"|'[^']*'|&?\d+|[^\s;&|]+)/u;
|
|
@@ -344,6 +363,7 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
344
363
|
}
|
|
345
364
|
else if (executable === "git" && tokens[1]?.toLowerCase() === "add") {
|
|
346
365
|
applies = true;
|
|
366
|
+
gitMutation = true;
|
|
347
367
|
const selected = exactGitAddPaths(tokens);
|
|
348
368
|
if (selected === undefined)
|
|
349
369
|
ambiguous = true;
|
|
@@ -352,6 +372,7 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
352
372
|
}
|
|
353
373
|
else if (executable === "git" && tokens[1]?.toLowerCase() === "commit") {
|
|
354
374
|
applies = true;
|
|
375
|
+
gitMutation = true;
|
|
355
376
|
if (isSafeGitCommit(tokens))
|
|
356
377
|
gitCommit = true;
|
|
357
378
|
else
|
|
@@ -361,7 +382,8 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
361
382
|
// Explicitly read-only git subcommands.
|
|
362
383
|
}
|
|
363
384
|
else if (/^gh(?:\.exe)?$/u.test(executable) && isRemoteOnlyGitHubCommand(tokens)) {
|
|
364
|
-
//
|
|
385
|
+
// Remote operations are pathless, but parallel workers must distinguish reads from mutations.
|
|
386
|
+
remoteMutation ||= isRemoteGitHubMutation(tokens);
|
|
365
387
|
}
|
|
366
388
|
else if (depth === 0 && /^(?:pwsh|powershell)(?:\.exe)?$/u.test(executable)) {
|
|
367
389
|
const literal = depthOnePowerShellLiteral(source);
|
|
@@ -376,6 +398,8 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
376
398
|
ambiguous ||= nested.ambiguous;
|
|
377
399
|
paths.push(...nested.paths);
|
|
378
400
|
gitCommit ||= nested.gitCommit === true;
|
|
401
|
+
gitMutation ||= nested.gitMutation === true;
|
|
402
|
+
remoteMutation ||= nested.remoteMutation === true;
|
|
379
403
|
issue ??= nested.issue;
|
|
380
404
|
}
|
|
381
405
|
}
|
|
@@ -385,7 +409,15 @@ function shellPaths(command, powershell, depth = 0) {
|
|
|
385
409
|
issue ??= commandIssue(source, "executable-not-allowlisted", "use a direct allowlisted read-only command");
|
|
386
410
|
}
|
|
387
411
|
}
|
|
388
|
-
return {
|
|
412
|
+
return {
|
|
413
|
+
applies,
|
|
414
|
+
ambiguous,
|
|
415
|
+
paths,
|
|
416
|
+
...(gitCommit ? { gitCommit: true } : {}),
|
|
417
|
+
...(gitMutation ? { gitMutation: true } : {}),
|
|
418
|
+
...(remoteMutation ? { remoteMutation: true } : {}),
|
|
419
|
+
...(issue ? { issue } : {}),
|
|
420
|
+
};
|
|
389
421
|
}
|
|
390
422
|
function issuePath(issue) {
|
|
391
423
|
return `segment=${issue.segment}; cause=${issue.cause}; hint=${issue.hint}; retry=false; action=return-denial-to-coordinator`;
|
|
@@ -426,11 +458,19 @@ export function extractWritePaths(tool, args) {
|
|
|
426
458
|
ambiguous: extracted.ambiguous,
|
|
427
459
|
paths: [...paths, ...extracted.paths],
|
|
428
460
|
...(extracted.gitCommit ? { gitCommit: true } : {}),
|
|
461
|
+
...(extracted.gitMutation ? { gitMutation: true } : {}),
|
|
462
|
+
...(extracted.remoteMutation ? { remoteMutation: true } : {}),
|
|
429
463
|
...(extracted.issue ? { issue: extracted.issue } : {}),
|
|
430
464
|
};
|
|
431
465
|
}
|
|
432
466
|
return { applies: false, ambiguous: false, paths: [] };
|
|
433
467
|
}
|
|
468
|
+
export function isGitMutation(tool, args) {
|
|
469
|
+
return extractWritePaths(tool, args).gitMutation === true;
|
|
470
|
+
}
|
|
471
|
+
export function isRemoteMutation(tool, args) {
|
|
472
|
+
return extractWritePaths(tool, args).remoteMutation === true;
|
|
473
|
+
}
|
|
434
474
|
/**
|
|
435
475
|
* Quoted segments keep their contents; every unquoted whitespace run collapses to one space so the
|
|
436
476
|
* same command written with different spacing compares equal.
|
|
@@ -492,6 +532,63 @@ async function nearestExistingRealPath(path) {
|
|
|
492
532
|
return await realpath(candidate);
|
|
493
533
|
}
|
|
494
534
|
}
|
|
535
|
+
function toggledCaseProbe(path) {
|
|
536
|
+
let candidate = resolve(path);
|
|
537
|
+
while (true) {
|
|
538
|
+
const name = basename(candidate);
|
|
539
|
+
const index = [...name].findIndex((character) => /[A-Za-z]/u.test(character));
|
|
540
|
+
if (index >= 0) {
|
|
541
|
+
const characters = [...name];
|
|
542
|
+
const character = characters[index];
|
|
543
|
+
characters[index] = character === character.toLowerCase()
|
|
544
|
+
? character.toUpperCase()
|
|
545
|
+
: character.toLowerCase();
|
|
546
|
+
return {
|
|
547
|
+
original: candidate,
|
|
548
|
+
alternate: resolve(candidate, "..", characters.join("")),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
const parent = resolve(candidate, "..");
|
|
552
|
+
if (parent === candidate)
|
|
553
|
+
return undefined;
|
|
554
|
+
candidate = parent;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async function isCaseInsensitivePath(path) {
|
|
558
|
+
if (process.platform === "win32")
|
|
559
|
+
return true;
|
|
560
|
+
const probe = toggledCaseProbe(path);
|
|
561
|
+
if (probe === undefined || probe.alternate === probe.original)
|
|
562
|
+
return false;
|
|
563
|
+
try {
|
|
564
|
+
return resolve(await realpath(probe.alternate)) === resolve(await realpath(probe.original));
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async function canonicalPotentialPath(path) {
|
|
571
|
+
let candidate = resolve(path);
|
|
572
|
+
const missing = [];
|
|
573
|
+
while (true) {
|
|
574
|
+
try {
|
|
575
|
+
const realCandidate = await realpath(candidate);
|
|
576
|
+
const canonical = resolve(realCandidate, ...missing);
|
|
577
|
+
return await isCaseInsensitivePath(candidate)
|
|
578
|
+
? canonical.toLowerCase()
|
|
579
|
+
: canonical;
|
|
580
|
+
}
|
|
581
|
+
catch (error) {
|
|
582
|
+
if (!isRecord(error) || error.code !== "ENOENT")
|
|
583
|
+
throw error;
|
|
584
|
+
const parent = resolve(candidate, "..");
|
|
585
|
+
if (parent === candidate)
|
|
586
|
+
throw error;
|
|
587
|
+
missing.unshift(basename(candidate));
|
|
588
|
+
candidate = parent;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
495
592
|
async function isSymbolicLink(path) {
|
|
496
593
|
try {
|
|
497
594
|
return (await lstat(path)).isSymbolicLink();
|
|
@@ -506,6 +603,26 @@ function isWithin(root, target) {
|
|
|
506
603
|
const path = relative(root, target);
|
|
507
604
|
return path === "" || (!path.startsWith("..") && !isAbsolute(path));
|
|
508
605
|
}
|
|
606
|
+
async function canonicalManifestScopes(project, entries) {
|
|
607
|
+
const scopes = await Promise.all(entries.map(async (entry) => {
|
|
608
|
+
const normalized = normalizeManifestPath(entry);
|
|
609
|
+
const absolute = normalized.kind === "relative"
|
|
610
|
+
? project.absolute(normalized.path)
|
|
611
|
+
: resolve(normalized.path);
|
|
612
|
+
return await canonicalPotentialPath(absolute);
|
|
613
|
+
}));
|
|
614
|
+
return [...new Set(scopes)];
|
|
615
|
+
}
|
|
616
|
+
/** Canonical scopes let sibling worker bindings reject equal or ancestor ownership. */
|
|
617
|
+
export async function canonicalManifestWriteScopes(project, manifest) {
|
|
618
|
+
return await canonicalManifestScopes(project, manifest.write);
|
|
619
|
+
}
|
|
620
|
+
export async function canonicalManifestReadScopes(project, manifest) {
|
|
621
|
+
return await canonicalManifestScopes(project, manifest.read);
|
|
622
|
+
}
|
|
623
|
+
export function writeScopesOverlap(left, right) {
|
|
624
|
+
return left.some((leftPath) => right.some((rightPath) => isWithin(leftPath, rightPath) || isWithin(rightPath, leftPath)));
|
|
625
|
+
}
|
|
509
626
|
export async function createProjectPaths(rootCandidate) {
|
|
510
627
|
const root = resolve(rootCandidate);
|
|
511
628
|
const realRoot = await realpath(root);
|