@avee1234/worklease 0.1.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.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/worklease.js +671 -0
- package/package.json +39 -0
- package/src/adapters/git-hook.js +164 -0
- package/src/check.js +43 -0
- package/src/claim.js +70 -0
- package/src/conformance.js +82 -0
- package/src/glob.js +92 -0
- package/src/index.js +39 -0
- package/src/registry.js +233 -0
- package/src/schema.js +230 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// worklease — git pre-commit hook adapter (the dogfood surface).
|
|
2
|
+
//
|
|
3
|
+
// This is what makes worklease actually prevent collisions in a live
|
|
4
|
+
// parallel-agent setup: a pre-commit hook that runs `worklease check` on the
|
|
5
|
+
// files a commit is about to change and surfaces any overlap with another
|
|
6
|
+
// agent's active claim. The git plumbing lives here; the coordination decision
|
|
7
|
+
// is delegated to the existing pure `check` core, so this adapter adds a new
|
|
8
|
+
// *surface*, not a second notion of "conflict".
|
|
9
|
+
//
|
|
10
|
+
// Design, matching roadmap principle #1 ("advisory, not enforcing"): the hook
|
|
11
|
+
// is warn-only by default (prints conflicts, exits 0, never blocks a commit).
|
|
12
|
+
// `worklease hook install --strict` bakes in blocking mode, where a conflict
|
|
13
|
+
// exits 1 and git aborts the commit. Both modes run the same `hook run` verb;
|
|
14
|
+
// the only difference is whether a conflict is fatal.
|
|
15
|
+
//
|
|
16
|
+
// Zero runtime dependencies: `git` is invoked via `child_process`, everything
|
|
17
|
+
// else reuses this package's own modules.
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
chmodSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { dirname, join, isAbsolute } from "node:path";
|
|
28
|
+
import { loadRegistry, defaultRegistryPath } from "../registry.js";
|
|
29
|
+
import { check } from "../check.js";
|
|
30
|
+
|
|
31
|
+
// Markers delimiting worklease's managed region inside `.git/hooks/pre-commit`.
|
|
32
|
+
// Install rewrites only the text *between* (and including) these lines, so any
|
|
33
|
+
// pre-existing hook body a user or another tool wrote is preserved verbatim.
|
|
34
|
+
const START = "# >>> worklease >>>";
|
|
35
|
+
const END = "# <<< worklease <<<";
|
|
36
|
+
|
|
37
|
+
// --- staged-path check (the check-on-staged-paths function) ----------------
|
|
38
|
+
|
|
39
|
+
// stagedPaths({ cwd }) → the repo-relative paths staged for the pending commit.
|
|
40
|
+
//
|
|
41
|
+
// These are the "files about to change". A staged path is already a concrete
|
|
42
|
+
// path, and every concrete path is a valid glob in the committed subset (a
|
|
43
|
+
// literal), so the paths feed straight into `check` — `check("src/auth/x.ts",
|
|
44
|
+
// …)` overlaps a claim on `src/auth/**`. No path→glob translation is needed.
|
|
45
|
+
// A non-git directory (or any git failure) yields [] — the caller then treats
|
|
46
|
+
// the commit as clear, since an advisory hook must never wedge a commit.
|
|
47
|
+
export function stagedPaths(opts = {}) {
|
|
48
|
+
const { cwd = process.cwd() } = opts;
|
|
49
|
+
const r = spawnSync("git", ["diff", "--cached", "--name-only"], {
|
|
50
|
+
cwd,
|
|
51
|
+
encoding: "utf8",
|
|
52
|
+
});
|
|
53
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return [];
|
|
54
|
+
return r.stdout
|
|
55
|
+
.split("\n")
|
|
56
|
+
.map((l) => l.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// checkStagedPaths(paths, { registry, agent, now }) → { clear, conflicts, notes }
|
|
61
|
+
//
|
|
62
|
+
// The core the hook runs: load the registry once and run the shared `check`
|
|
63
|
+
// core over the staged paths. Empty input short-circuits to clear (an empty or
|
|
64
|
+
// docs-only commit can never collide). `notes` carries loadRegistry's
|
|
65
|
+
// skipped/tampered/expired warnings so the caller can surface them — a dropped
|
|
66
|
+
// line means `check` might call a held path clear, the exact collision this
|
|
67
|
+
// tool exists to catch.
|
|
68
|
+
export function checkStagedPaths(paths, opts = {}) {
|
|
69
|
+
const { registry = null, agent = null, now = Date.now() } = opts;
|
|
70
|
+
if (!paths || paths.length === 0) {
|
|
71
|
+
return { clear: true, conflicts: [], notes: [] };
|
|
72
|
+
}
|
|
73
|
+
const path = registry || defaultRegistryPath();
|
|
74
|
+
const { claims, notes } = loadRegistry(path, { now });
|
|
75
|
+
const result = check(paths, claims, { agent, now });
|
|
76
|
+
return { clear: result.clear, conflicts: result.conflicts, notes };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- hook install ----------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
// gitPath(cwd, rel) → the absolute path of a file inside the git dir, or null
|
|
82
|
+
// if cwd is not a git repo. Uses `git rev-parse --git-path` so it resolves
|
|
83
|
+
// correctly for worktrees (where hooks live in the shared common dir) — the
|
|
84
|
+
// exact setup worklease targets, since fleets run in parallel worktrees.
|
|
85
|
+
function gitPath(cwd, rel) {
|
|
86
|
+
const r = spawnSync("git", ["rev-parse", "--git-path", rel], {
|
|
87
|
+
cwd,
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
});
|
|
90
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return null;
|
|
91
|
+
const p = r.stdout.trim();
|
|
92
|
+
if (!p) return null;
|
|
93
|
+
return isAbsolute(p) ? p : join(cwd, p);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// hookPath(cwd) → absolute path of this repo's pre-commit hook, or null if cwd
|
|
97
|
+
// is not a git repository.
|
|
98
|
+
export function hookPath(cwd = process.cwd()) {
|
|
99
|
+
return gitPath(cwd, "hooks/pre-commit");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// renderHookBlock(strict) → the marker-delimited shell block install writes.
|
|
103
|
+
//
|
|
104
|
+
// The block delegates to `worklease hook run`, guarded by `command -v` so a
|
|
105
|
+
// clone where `worklease` isn't on PATH degrades gracefully (no hook error,
|
|
106
|
+
// consistent with advisory-first). The strict/warn policy lives entirely in
|
|
107
|
+
// `hook run`; install just chooses whether to pass `--strict`, keeping the
|
|
108
|
+
// exit-code decision in one place.
|
|
109
|
+
export function renderHookBlock(strict = false) {
|
|
110
|
+
return [
|
|
111
|
+
START,
|
|
112
|
+
"# Managed by `worklease hook install` — runs `worklease check` on staged files.",
|
|
113
|
+
strict
|
|
114
|
+
? "# Mode: strict — a claim conflict blocks the commit (exit 1)."
|
|
115
|
+
: "# Mode: advisory — conflicts are printed but never block the commit.",
|
|
116
|
+
"if command -v worklease >/dev/null 2>&1; then",
|
|
117
|
+
strict ? " worklease hook run --strict" : " worklease hook run",
|
|
118
|
+
"fi",
|
|
119
|
+
END,
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// installHook({ cwd, strict }) → { path, action, strict }
|
|
124
|
+
//
|
|
125
|
+
// Idempotent, existing-hook-preserving install:
|
|
126
|
+
// - no pre-commit hook yet → create one (shebang + block) ["created"]
|
|
127
|
+
// - a hook with our markers → replace only the marked block ["updated"]
|
|
128
|
+
// - a hook without our markers → append the block after it ["appended"]
|
|
129
|
+
// Re-running install therefore converges to a single managed block and never
|
|
130
|
+
// duplicates it or clobbers a hand-written hook. The file is chmod +x so git
|
|
131
|
+
// will execute it.
|
|
132
|
+
export function installHook(opts = {}) {
|
|
133
|
+
const { cwd = process.cwd(), strict = false } = opts;
|
|
134
|
+
const path = hookPath(cwd);
|
|
135
|
+
if (!path) {
|
|
136
|
+
throw new Error("not a git repository (run `git init` first)");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const block = renderHookBlock(strict);
|
|
140
|
+
let content;
|
|
141
|
+
let action;
|
|
142
|
+
|
|
143
|
+
if (!existsSync(path)) {
|
|
144
|
+
content = `#!/bin/sh\n${block}\n`;
|
|
145
|
+
action = "created";
|
|
146
|
+
} else {
|
|
147
|
+
const existing = readFileSync(path, "utf8");
|
|
148
|
+
const s = existing.indexOf(START);
|
|
149
|
+
const e = existing.indexOf(END);
|
|
150
|
+
if (s !== -1 && e !== -1 && e > s) {
|
|
151
|
+
content = existing.slice(0, s) + block + existing.slice(e + END.length);
|
|
152
|
+
action = "updated";
|
|
153
|
+
} else {
|
|
154
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
155
|
+
content = `${existing}${sep}\n${block}\n`;
|
|
156
|
+
action = "appended";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
161
|
+
writeFileSync(path, content);
|
|
162
|
+
chmodSync(path, 0o755);
|
|
163
|
+
return { path, action, strict };
|
|
164
|
+
}
|
package/src/check.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// worklease — `check` core (pure orchestration over `globsOverlap`).
|
|
2
|
+
//
|
|
3
|
+
// Given planned-edit globs and a resolved registry array, report whether the
|
|
4
|
+
// plan overlaps any *active* claim held by *another* agent. No filesystem, no
|
|
5
|
+
// clock: time is injected as `opts.now` so expiry is deterministic in tests.
|
|
6
|
+
|
|
7
|
+
import { globsOverlap } from "./glob.js";
|
|
8
|
+
|
|
9
|
+
// check(plannedGlobs, registry, opts) → { clear, conflicts }
|
|
10
|
+
//
|
|
11
|
+
// opts.agent — the caller's id; a claim by this agent counts as clear (you may
|
|
12
|
+
// edit what you already hold). If null/undefined, no same-agent
|
|
13
|
+
// filtering is applied (every active claim is treated as another
|
|
14
|
+
// agent's — the safe default).
|
|
15
|
+
// opts.now — epoch ms used to evaluate `expires`; injected for determinism.
|
|
16
|
+
//
|
|
17
|
+
// A claim is a conflict iff ALL of: status === "active", its `expires` is still
|
|
18
|
+
// in the future, its `agent` differs from the caller, and at least one of its
|
|
19
|
+
// globs intersects a planned glob. Each conflict's `overlapping_globs` is the
|
|
20
|
+
// deduped, sorted subset of THAT claim's globs which overlap the plan.
|
|
21
|
+
export function check(plannedGlobs, registry, opts = {}) {
|
|
22
|
+
const { agent = null, now = Date.now() } = opts;
|
|
23
|
+
|
|
24
|
+
const conflicts = [];
|
|
25
|
+
for (const claim of registry) {
|
|
26
|
+
if (claim.status !== "active") continue;
|
|
27
|
+
if (!(Date.parse(claim.expires) > now)) continue; // expired-by-time = clear
|
|
28
|
+
if (agent != null && claim.agent === agent) continue; // own claim = clear
|
|
29
|
+
|
|
30
|
+
const overlapping = claim.globs.filter((g) =>
|
|
31
|
+
plannedGlobs.some((p) => globsOverlap(p, g))
|
|
32
|
+
);
|
|
33
|
+
if (overlapping.length) {
|
|
34
|
+
conflicts.push({ claim, overlapping_globs: dedupeSort(overlapping) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { clear: conflicts.length === 0, conflicts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dedupeSort(globs) {
|
|
42
|
+
return [...new Set(globs)].sort();
|
|
43
|
+
}
|
package/src/claim.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// worklease — `claim` core (pure claim constructor + ttl parsing).
|
|
2
|
+
//
|
|
3
|
+
// `makeClaim` builds a valid claim record with a deterministic content-hash id
|
|
4
|
+
// and a computed `expires`, with NO I/O and NO clock — `created` is injected via
|
|
5
|
+
// `meta`, so the result is fully determined by its inputs and unit-testable. The
|
|
6
|
+
// CLI (`bin/worklease.js`) is the only part that reads the clock and appends to
|
|
7
|
+
// the registry.
|
|
8
|
+
|
|
9
|
+
import { computeRecordId } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
// makeClaim(globs, meta) → a claim record matching #1's CLAIM_FIELDS order.
|
|
12
|
+
//
|
|
13
|
+
// meta.agent — who is filing the claim
|
|
14
|
+
// meta.intent — why (what work the globs are for)
|
|
15
|
+
// meta.ttl_seconds — lease length in whole seconds (integer)
|
|
16
|
+
// meta.created — ISO-8601-UTC timestamp the claim is filed at
|
|
17
|
+
//
|
|
18
|
+
// `expires` is `created + ttl_seconds` to the millisecond, so #1's
|
|
19
|
+
// EXPIRES_MISMATCH cross-check holds by construction. `id` is the registry's
|
|
20
|
+
// shared content hash of the whole record (its `id` excluded), so a claim's id
|
|
21
|
+
// IS its content hash and it resolves cleanly through #4's store — one hasher
|
|
22
|
+
// across every record type. `status` is always "active" on creation. Pure and
|
|
23
|
+
// total: it does not throw and does no validation — the CLI validates the
|
|
24
|
+
// finished record via #1's `validateClaim`.
|
|
25
|
+
export function makeClaim(globs, meta = {}) {
|
|
26
|
+
const { agent, intent, ttl_seconds, created } = meta;
|
|
27
|
+
const expires = isoAddSeconds(created, ttl_seconds);
|
|
28
|
+
const record = {
|
|
29
|
+
agent,
|
|
30
|
+
globs,
|
|
31
|
+
intent,
|
|
32
|
+
ttl_seconds,
|
|
33
|
+
created,
|
|
34
|
+
expires,
|
|
35
|
+
status: "active",
|
|
36
|
+
};
|
|
37
|
+
return { id: computeRecordId(record), ...record };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// parseTtl(input) → integer seconds, or null on anything invalid.
|
|
41
|
+
//
|
|
42
|
+
// Accepts a compact duration shorthand (`<n>s`, `<n>m`, `<n>h`) or a bare
|
|
43
|
+
// positive integer interpreted as seconds (string or number). A bad unit, a
|
|
44
|
+
// non-integer, zero, a negative, or empty input returns null (never throws) so
|
|
45
|
+
// the caller can print one clear error and exit.
|
|
46
|
+
export function parseTtl(input) {
|
|
47
|
+
if (typeof input === "number") {
|
|
48
|
+
return Number.isInteger(input) && input > 0 ? input : null;
|
|
49
|
+
}
|
|
50
|
+
if (typeof input !== "string") return null;
|
|
51
|
+
|
|
52
|
+
const s = input.trim();
|
|
53
|
+
if (/^\d+$/.test(s)) {
|
|
54
|
+
const n = Number(s);
|
|
55
|
+
return n > 0 ? n : null; // bare seconds; "0" → null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const m = /^(\d+)(s|m|h)$/.exec(s);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
const n = Number(m[1]);
|
|
61
|
+
if (n <= 0) return null; // "0m" → null
|
|
62
|
+
const mult = { s: 1, m: 60, h: 3600 }[m[2]];
|
|
63
|
+
return n * mult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// created + ttl_seconds as an ISO-8601-UTC string. With whole-second `created`
|
|
67
|
+
// and integer `ttl_seconds`, epoch-ms equality with #1's derived value holds.
|
|
68
|
+
function isoAddSeconds(created, ttl_seconds) {
|
|
69
|
+
return new Date(Date.parse(created) + ttl_seconds * 1000).toISOString();
|
|
70
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// worklease — `conformance` core (pure orchestration over `globsOverlap`).
|
|
2
|
+
//
|
|
3
|
+
// The after-the-fact metric: given a resolved registry and a set of *merges*
|
|
4
|
+
// (the files each agent actually touched), score whether the fleet coordinated.
|
|
5
|
+
// For each `(agent, file)` change it asks two questions — did the acting agent
|
|
6
|
+
// hold a claim covering the file, and did the file fall under a *different*
|
|
7
|
+
// agent's live claim? — and partitions every change into exactly one of
|
|
8
|
+
// {respected, violation, warning}. No filesystem, no clock: all time comparisons
|
|
9
|
+
// use the merge record's own `at` (or fall back to claim `status`), so the core
|
|
10
|
+
// is pure and deterministic.
|
|
11
|
+
//
|
|
12
|
+
// A touched file is a concrete (wildcard-free) glob, so "does file F fall under
|
|
13
|
+
// claim glob G?" is exactly `globsOverlap(F, G)` from #3 — no new matching logic.
|
|
14
|
+
|
|
15
|
+
import { globsOverlap } from "./glob.js";
|
|
16
|
+
import { isIso8601Utc } from "./schema.js";
|
|
17
|
+
|
|
18
|
+
// Is claim `c` held/active at instant `at` (ISO string)? Temporal window
|
|
19
|
+
// [created, expires): created ≤ at < expires. Malformed timestamps are treated
|
|
20
|
+
// as "not held/active" (conservative — never invents coverage or a violation).
|
|
21
|
+
function within(c, at) {
|
|
22
|
+
if (!isIso8601Utc(at) || !isIso8601Utc(c.created) || !isIso8601Utc(c.expires)) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const t = Date.parse(at);
|
|
26
|
+
return Date.parse(c.created) <= t && t < Date.parse(c.expires);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Status fallback for coverage when a change has no `at`: any non-expired claim.
|
|
30
|
+
function notExpired(c) {
|
|
31
|
+
return c.status !== "expired";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// conformance(claims, merges, opts) → { score, total, respected, violations, warnings }
|
|
35
|
+
//
|
|
36
|
+
// claims — resolved registry array (latest record per id), each claim
|
|
37
|
+
// `{ id, agent, globs[], intent, ttl_seconds, created, expires, status }`.
|
|
38
|
+
// merges — array of `{ agent, files: string[], at? }`; flattened to one change
|
|
39
|
+
// per `(agent, file)`.
|
|
40
|
+
// opts — reserved; no clock needed (this is an after-the-fact audit).
|
|
41
|
+
//
|
|
42
|
+
// A change by agent A on file F at time T is:
|
|
43
|
+
// - covered — A held a matching claim at T (temporal `within` if `at`,
|
|
44
|
+
// else non-expired status).
|
|
45
|
+
// - violation — a DIFFERENT agent B held a matching claim live at T (temporal
|
|
46
|
+
// if `at`, else `status === "active"`); one entry per such claim.
|
|
47
|
+
// - respected — covered ∧ not a violation (the coordination numerator).
|
|
48
|
+
// - warning — uncovered ∧ not a violation (edited an unclaimed file).
|
|
49
|
+
// A colliding change is a violation even if A also held a claim (double-claim).
|
|
50
|
+
export function conformance(claims, merges, opts = {}) {
|
|
51
|
+
const changes = merges.flatMap((m) =>
|
|
52
|
+
m.files.map((file) => ({ agent: m.agent, file, at: m.at }))
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const violations = [];
|
|
56
|
+
const warnings = [];
|
|
57
|
+
let respected = 0;
|
|
58
|
+
|
|
59
|
+
for (const { agent, file, at } of changes) {
|
|
60
|
+
const matching = claims.filter((c) => c.globs.some((g) => globsOverlap(file, g)));
|
|
61
|
+
|
|
62
|
+
const held = (c) => (at != null ? within(c, at) : notExpired(c));
|
|
63
|
+
const live = (c) => (at != null ? within(c, at) : c.status === "active");
|
|
64
|
+
|
|
65
|
+
const covered = matching.some((c) => c.agent === agent && held(c));
|
|
66
|
+
const colliding = matching.filter((c) => c.agent !== agent && live(c));
|
|
67
|
+
|
|
68
|
+
if (colliding.length) {
|
|
69
|
+
for (const c of colliding) {
|
|
70
|
+
violations.push({ agent, file, conflicting_claim: c });
|
|
71
|
+
}
|
|
72
|
+
} else if (covered) {
|
|
73
|
+
respected += 1;
|
|
74
|
+
} else {
|
|
75
|
+
warnings.push({ agent, file });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const total = changes.length;
|
|
80
|
+
const score = total === 0 ? 1 : respected / total;
|
|
81
|
+
return { score, total, respected, violations, warnings };
|
|
82
|
+
}
|
package/src/glob.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// worklease — glob intersection (pure, zero-dependency, no filesystem access).
|
|
2
|
+
//
|
|
3
|
+
// Decides whether two globs from the committed subset (`**`, `*`, `/`, literals)
|
|
4
|
+
// could match a common concrete path. This is the conservative *satisfiability*
|
|
5
|
+
// rule used by `check`: two globs overlap iff there EXISTS at least one concrete
|
|
6
|
+
// path that matches both — evaluated purely over the glob strings, never the
|
|
7
|
+
// working tree, so it is safe for a check that runs *before* the edit (the file
|
|
8
|
+
// may not exist yet). Symmetric by construction: overlap(a, b) === overlap(b, a).
|
|
9
|
+
//
|
|
10
|
+
// The problem is pattern-vs-pattern satisfiability at two levels — across
|
|
11
|
+
// segments (`**` spans zero or more whole segments) and within a segment (`*`
|
|
12
|
+
// matches any run of characters but never crosses `/`). Each level is a memoized
|
|
13
|
+
// two-pointer recursion, bounding work to O(m·n) so stacked `**` cannot blow up.
|
|
14
|
+
|
|
15
|
+
// Normalize a glob string into an array of segment tokens.
|
|
16
|
+
// - strip a single leading "./"
|
|
17
|
+
// - collapse repeated "/" into one
|
|
18
|
+
// - drop a single trailing "/" (treat `src/auth/` as `src/auth`)
|
|
19
|
+
// - split on "/"
|
|
20
|
+
// - within each non-"**" segment, collapse any run of "*" to a single "*"
|
|
21
|
+
// (so `a**b` → `a*b`); "**" keeps its cross-segment meaning only when it is
|
|
22
|
+
// the entire segment.
|
|
23
|
+
function normalize(glob) {
|
|
24
|
+
let g = glob;
|
|
25
|
+
if (g.startsWith("./")) g = g.slice(2);
|
|
26
|
+
g = g.replace(/\/+/g, "/");
|
|
27
|
+
if (g.length > 1 && g.endsWith("/")) g = g.slice(0, -1);
|
|
28
|
+
return g
|
|
29
|
+
.split("/")
|
|
30
|
+
.map((seg) => (seg === "**" ? "**" : seg.replace(/\*+/g, "*")));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Within-segment overlap: do single-segment patterns `a` and `b` generate a
|
|
34
|
+
// common string? `*` matches any run of characters (including empty); every
|
|
35
|
+
// other character is a case-sensitive literal. Memoized two-pointer over chars.
|
|
36
|
+
function segmentOverlap(a, b) {
|
|
37
|
+
const memo = new Map();
|
|
38
|
+
const seg = (i, j) => {
|
|
39
|
+
if (i === a.length && j === b.length) return true;
|
|
40
|
+
const key = i * (b.length + 1) + j;
|
|
41
|
+
if (memo.has(key)) return memo.get(key);
|
|
42
|
+
let res;
|
|
43
|
+
if (i < a.length && a[i] === "*") {
|
|
44
|
+
// `*` consumes zero chars of `b`, or one-or-more (advance `b`).
|
|
45
|
+
res = seg(i + 1, j) || (j < b.length && seg(i, j + 1));
|
|
46
|
+
} else if (j < b.length && b[j] === "*") {
|
|
47
|
+
res = seg(i, j + 1) || (i < a.length && seg(i + 1, j));
|
|
48
|
+
} else if (i === a.length || j === b.length) {
|
|
49
|
+
res = false; // one side ran out with a literal still pending
|
|
50
|
+
} else if (a[i] === b[j]) {
|
|
51
|
+
res = seg(i + 1, j + 1);
|
|
52
|
+
} else {
|
|
53
|
+
res = false;
|
|
54
|
+
}
|
|
55
|
+
memo.set(key, res);
|
|
56
|
+
return res;
|
|
57
|
+
};
|
|
58
|
+
return seg(0, 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Segment-level overlap: `A`, `B` are segment-token arrays. A token is either
|
|
62
|
+
// the literal "**" (matches zero or more whole segments) or a single-segment
|
|
63
|
+
// pattern. Memoized on (i, j) so "**"-vs-"**" stays O(|A|·|B|).
|
|
64
|
+
function segmentsOverlap(A, B) {
|
|
65
|
+
const memo = new Map();
|
|
66
|
+
const overlap = (i, j) => {
|
|
67
|
+
if (i === A.length && j === B.length) return true;
|
|
68
|
+
const key = i * (B.length + 1) + j;
|
|
69
|
+
if (memo.has(key)) return memo.get(key);
|
|
70
|
+
let res;
|
|
71
|
+
if (i < A.length && A[i] === "**") {
|
|
72
|
+
// "**" matches zero segments, or one-or-more (consume a B segment).
|
|
73
|
+
res = overlap(i + 1, j) || (j < B.length && overlap(i, j + 1));
|
|
74
|
+
} else if (j < B.length && B[j] === "**") {
|
|
75
|
+
res = overlap(i, j + 1) || (i < A.length && overlap(i + 1, j));
|
|
76
|
+
} else if (i === A.length || j === B.length) {
|
|
77
|
+
res = false; // one side ran out with a real segment still pending
|
|
78
|
+
} else if (!segmentOverlap(A[i], B[j])) {
|
|
79
|
+
res = false; // this pair of segments can't align
|
|
80
|
+
} else {
|
|
81
|
+
res = overlap(i + 1, j + 1); // aligned — advance both
|
|
82
|
+
}
|
|
83
|
+
memo.set(key, res);
|
|
84
|
+
return res;
|
|
85
|
+
};
|
|
86
|
+
return overlap(0, 0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Public API: do two globs share at least one concrete matching path?
|
|
90
|
+
export function globsOverlap(globA, globB) {
|
|
91
|
+
return segmentsOverlap(normalize(globA), normalize(globB));
|
|
92
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// worklease — public entry point (package `main`).
|
|
2
|
+
// Re-exports the pure schema/validator API.
|
|
3
|
+
// Re-exports the pure `check` / glob-overlap API.
|
|
4
|
+
// Re-exports the pure `conformance` after-the-fact coordination-score API.
|
|
5
|
+
// Re-exports the pure `makeClaim` / `parseTtl` claim-constructor API.
|
|
6
|
+
// Re-exports the append-only registry store (`loadRegistry`, `appendRecord`, …).
|
|
7
|
+
// Re-exports the git pre-commit adapter (`checkStagedPaths`, `installHook`, …).
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
validateClaim,
|
|
11
|
+
validateRegistry,
|
|
12
|
+
isIso8601Utc,
|
|
13
|
+
isAllowedGlob,
|
|
14
|
+
STATUSES,
|
|
15
|
+
CLAIM_FIELDS,
|
|
16
|
+
ERROR_CODES,
|
|
17
|
+
} from "./schema.js";
|
|
18
|
+
export { globsOverlap } from "./glob.js";
|
|
19
|
+
export { check } from "./check.js";
|
|
20
|
+
export { conformance } from "./conformance.js";
|
|
21
|
+
export { makeClaim, parseTtl } from "./claim.js";
|
|
22
|
+
export {
|
|
23
|
+
canonicalize,
|
|
24
|
+
computeRecordId,
|
|
25
|
+
resolveRecords,
|
|
26
|
+
loadRegistry,
|
|
27
|
+
appendRecord,
|
|
28
|
+
defaultRegistryPath,
|
|
29
|
+
listActive,
|
|
30
|
+
formatRelative,
|
|
31
|
+
shortId,
|
|
32
|
+
} from "./registry.js";
|
|
33
|
+
export {
|
|
34
|
+
stagedPaths,
|
|
35
|
+
checkStagedPaths,
|
|
36
|
+
hookPath,
|
|
37
|
+
renderHookBlock,
|
|
38
|
+
installHook,
|
|
39
|
+
} from "./adapters/git-hook.js";
|