filegrc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.js ADDED
@@ -0,0 +1,289 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { serializeWorkspaceMutation } from "./mutation.js";
3
+ import { resolveWorkspaceRoot } from "./paths.js";
4
+ import { validateWorkspace } from "./validate.js";
5
+
6
+ export function getGitSummary(input = process.cwd()) {
7
+ const root = resolveWorkspaceRoot(input);
8
+ try {
9
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
10
+ const status = git(root, ["status", "--porcelain=v1", "--", "."]);
11
+ const commit = tryGit(root, ["rev-parse", "HEAD"]) || null;
12
+ const branch = tryGit(root, ["symbolic-ref", "--short", "HEAD"]) || null;
13
+ const upstream = branch ? tryGit(root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) || null : null;
14
+ const remotes = lines(tryGit(root, ["remote"]));
15
+ const last = commit ? parseLogLine(tryGit(root, ["log", "-1", "--format=%H%x1f%aI%x1f%an%x1f%s"])) : null;
16
+ return {
17
+ available: true,
18
+ root: topLevel,
19
+ commit,
20
+ shortCommit: commit?.slice(0, 8) ?? "no commits",
21
+ branch,
22
+ upstream,
23
+ remotes,
24
+ clean: status === "",
25
+ changes: status ? status.split("\n") : [],
26
+ lastCommit: last
27
+ };
28
+ } catch (error) {
29
+ return {
30
+ available: false,
31
+ clean: null,
32
+ changes: [],
33
+ message: "Git history is unavailable. Commit the workspace to enable audit metadata."
34
+ };
35
+ }
36
+ }
37
+
38
+ export function getFileHistory(input, relativePath, limit = 50) {
39
+ const root = resolveWorkspaceRoot(input);
40
+ try {
41
+ const output = git(root, [
42
+ "log",
43
+ "--follow",
44
+ `--max-count=${Math.max(1, Math.min(Number(limit) || 50, 200))}`,
45
+ "--format=%H%x1f%aI%x1f%an%x1f%s",
46
+ "--",
47
+ relativePath
48
+ ]);
49
+ if (!output) return [];
50
+ return output.split("\n").map(parseLogLine);
51
+ } catch {
52
+ return [];
53
+ }
54
+ }
55
+
56
+ export function getWorkspaceHistories(input, relativePaths, limitPerFile = 12) {
57
+ const root = resolveWorkspaceRoot(input);
58
+ const wanted = new Set(relativePaths);
59
+ const histories = new Map([...wanted].map((path) => [path, []]));
60
+ if (!wanted.size) return histories;
61
+ try {
62
+ const output = git(root, ["log", "--relative", "--format=%x1e%H%x1f%aI%x1f%an%x1f%s", "--name-only", "--", "data"]);
63
+ for (const block of output.split("\x1e")) {
64
+ const lines = block.trim().split("\n").filter(Boolean);
65
+ if (lines.length < 2) continue;
66
+ const commit = parseLogLine(lines[0]);
67
+ for (const path of lines.slice(1)) {
68
+ const history = histories.get(path);
69
+ if (history && history.length < limitPerFile) history.push(commit);
70
+ }
71
+ }
72
+ } catch {
73
+ // An uncommitted workspace has no history yet.
74
+ }
75
+ return histories;
76
+ }
77
+
78
+ export function getFileAtRevision(input, revision, relativePath) {
79
+ const root = resolveWorkspaceRoot(input);
80
+ if (!/^[a-f0-9]{40}$/i.test(String(revision)) || typeof relativePath !== "string" || !relativePath.startsWith("data/")) {
81
+ throw new Error("Historical file exports require a Git commit and a data/ path.");
82
+ }
83
+ try {
84
+ return execFileSync("git", ["show", `${revision}:${relativePath}`], {
85
+ cwd: root,
86
+ encoding: "utf8",
87
+ stdio: ["ignore", "pipe", "ignore"],
88
+ timeout: 10_000,
89
+ maxBuffer: 20_000_000
90
+ });
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export function hasGitRevision(input, revision) {
97
+ if (!/^[a-f0-9]{40}$/i.test(String(revision))) return false;
98
+ const root = resolveWorkspaceRoot(input);
99
+ try {
100
+ git(root, ["cat-file", "-e", `${revision}^{commit}`]);
101
+ return true;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ export async function commitWorkspace(input, message) {
108
+ return serializeWorkspaceMutation(input, (root) => commitWorkspaceUnlocked(root, message));
109
+ }
110
+
111
+ export async function commitAndPushWorkspace(input, message) {
112
+ return serializeWorkspaceMutation(input, (root) => commitAndPushWorkspaceUnlocked(root, message));
113
+ }
114
+
115
+ export async function pullWorkspace(input = process.cwd()) {
116
+ return serializeWorkspaceMutation(input, pullWorkspaceUnlocked);
117
+ }
118
+
119
+ export async function pushWorkspace(input = process.cwd()) {
120
+ return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
121
+ }
122
+
123
+ async function commitWorkspaceUnlocked(root, message) {
124
+ const subject = String(message ?? "").trim();
125
+ if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
126
+ throw new Error("Commit messages must be one line from 1 through 200 characters.");
127
+ }
128
+ const validation = await validateWorkspace(root);
129
+ if (!validation.ok) {
130
+ throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before committing.`);
131
+ }
132
+ const before = getGitSummary(root);
133
+ if (!before.available) throw new Error("Git history is unavailable for this workspace.");
134
+ if (before.clean) throw new Error("The workspace has no changes to commit.");
135
+ if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
136
+ throw new Error("Configure git user.name and user.email before committing.");
137
+ }
138
+ gitForWrite(root, ["add", "--all", "--", "."]);
139
+ gitForWrite(root, ["commit", "-m", subject, "--", "."]);
140
+ const after = getGitSummary(root);
141
+ return {
142
+ commit: after.commit,
143
+ shortCommit: after.shortCommit,
144
+ subject: after.lastCommit?.subject || subject
145
+ };
146
+ }
147
+
148
+ async function commitAndPushWorkspaceUnlocked(root, message) {
149
+ const before = getGitSummary(root);
150
+ const committed = await commitWorkspaceUnlocked(root, message);
151
+ if (!before.upstream && !before.remotes?.length) {
152
+ return {
153
+ ...committed,
154
+ pushed: false,
155
+ pushSkipped: true
156
+ };
157
+ }
158
+ try {
159
+ const pushed = await pushWorkspaceUnlocked(root);
160
+ return {
161
+ ...committed,
162
+ pushed: true,
163
+ pushSkipped: false,
164
+ upstream: pushed.upstream
165
+ };
166
+ } catch (error) {
167
+ return {
168
+ ...committed,
169
+ pushed: false,
170
+ pushSkipped: false,
171
+ pushError: error.message
172
+ };
173
+ }
174
+ }
175
+
176
+ async function pullWorkspaceUnlocked(root) {
177
+ const before = syncReadySummary(root, "pull");
178
+ if (!before.upstream) {
179
+ throw new Error("This branch has no upstream branch. Push it first or configure an upstream with Git.");
180
+ }
181
+ try {
182
+ gitForWrite(root, ["pull", "--rebase", "--no-autostash"], "pull with rebase");
183
+ } catch (error) {
184
+ tryGitForWrite(root, ["rebase", "--abort"]);
185
+ throw error;
186
+ }
187
+ const after = getGitSummary(root);
188
+ return {
189
+ updated: before.commit !== after.commit,
190
+ commit: after.commit,
191
+ shortCommit: after.shortCommit,
192
+ branch: after.branch,
193
+ upstream: after.upstream
194
+ };
195
+ }
196
+
197
+ async function pushWorkspaceUnlocked(root) {
198
+ const before = syncReadySummary(root, "push");
199
+ const validation = await validateWorkspace(root);
200
+ if (!validation.ok) {
201
+ throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. Fix them before pushing.`);
202
+ }
203
+ if (before.upstream) {
204
+ gitForWrite(root, ["push"], "push");
205
+ } else {
206
+ const remote = before.remotes.includes("origin")
207
+ ? "origin"
208
+ : before.remotes.length === 1
209
+ ? before.remotes[0]
210
+ : null;
211
+ if (!remote) {
212
+ throw new Error(before.remotes.length
213
+ ? "This branch has no upstream and the repository has multiple remotes. Configure an upstream with Git."
214
+ : "This repository has no Git remote. Add one before pushing.");
215
+ }
216
+ gitForWrite(root, ["push", "--set-upstream", "--", remote, "HEAD"], "push");
217
+ }
218
+ const after = getGitSummary(root);
219
+ return {
220
+ commit: after.commit,
221
+ shortCommit: after.shortCommit,
222
+ branch: after.branch,
223
+ upstream: after.upstream
224
+ };
225
+ }
226
+
227
+ function syncReadySummary(root, action) {
228
+ const summary = getGitSummary(root);
229
+ if (!summary.available) throw new Error(`Git history is unavailable for this workspace, so FileGRC cannot ${action}.`);
230
+ if (!summary.branch) throw new Error(`Check out a branch before trying to ${action}.`);
231
+ if (!summary.clean) throw new Error(`Commit or discard workspace changes before trying to ${action}.`);
232
+ return summary;
233
+ }
234
+
235
+ function parseLogLine(line) {
236
+ if (!line) return null;
237
+ const [commit, timestamp, author, subject] = line.split("\x1f");
238
+ return { commit, shortCommit: commit?.slice(0, 8), timestamp, author, subject };
239
+ }
240
+
241
+ function lines(source) {
242
+ return source ? source.split("\n").filter(Boolean) : [];
243
+ }
244
+
245
+ function git(cwd, args) {
246
+ return execFileSync("git", args, {
247
+ cwd,
248
+ encoding: "utf8",
249
+ stdio: ["ignore", "pipe", "ignore"],
250
+ timeout: 10_000,
251
+ maxBuffer: 20_000_000
252
+ }).trim();
253
+ }
254
+
255
+ function tryGit(cwd, args) {
256
+ try {
257
+ return git(cwd, args);
258
+ } catch {
259
+ return "";
260
+ }
261
+ }
262
+
263
+ function gitForWrite(cwd, args, action = "create the commit") {
264
+ try {
265
+ return execFileSync("git", args, {
266
+ cwd,
267
+ encoding: "utf8",
268
+ stdio: ["ignore", "pipe", "pipe"],
269
+ timeout: 30_000,
270
+ maxBuffer: 20_000_000,
271
+ env: {
272
+ ...process.env,
273
+ GIT_TERMINAL_PROMPT: "0",
274
+ GIT_MERGE_AUTOEDIT: "no"
275
+ }
276
+ }).trim();
277
+ } catch (error) {
278
+ const message = error.stderr?.trim() || error.stdout?.trim() || error.message;
279
+ throw new Error(`Git could not ${action}. ${message}`);
280
+ }
281
+ }
282
+
283
+ function tryGitForWrite(cwd, args) {
284
+ try {
285
+ gitForWrite(cwd, args);
286
+ } catch {
287
+ // Best-effort cleanup after a failed remote operation.
288
+ }
289
+ }
package/src/id.js ADDED
@@ -0,0 +1,19 @@
1
+ export function createResourceId(type, title, existingIds = []) {
2
+ const slugPart = (value) => String(value)
3
+ .normalize("NFKD")
4
+ .replace(/[\u0300-\u036f]/g, "")
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-|-$/g, "");
8
+ const prefix = slugPart(type) || "record";
9
+ const name = slugPart(title) || "new";
10
+ const base = `${prefix}-${name}`;
11
+ const used = new Set([...existingIds].map(String));
12
+ let candidate = base;
13
+ let suffix = 2;
14
+ while (used.has(candidate)) {
15
+ candidate = `${base}-${suffix}`;
16
+ suffix += 1;
17
+ }
18
+ return candidate;
19
+ }
package/src/index.js ADDED
@@ -0,0 +1,47 @@
1
+ export { getResourceDefinition, loadModel } from "../model/index.js";
2
+ export { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
3
+ export { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
4
+ export { buildWorkspace } from "./build.js";
5
+ export { generateEvidencePacket, prepareEvidencePacket, writeEvidencePacket } from "./evidence-packet.js";
6
+ export {
7
+ addEvidenceAttachment,
8
+ createResource,
9
+ createResourceAndLink,
10
+ createResources,
11
+ deleteResource,
12
+ removeEvidenceAttachment,
13
+ resourcePath,
14
+ updateContent,
15
+ updateResource
16
+ } from "./files.js";
17
+ export {
18
+ commitAndPushWorkspace,
19
+ commitWorkspace,
20
+ getFileHistory,
21
+ getGitSummary,
22
+ getWorkspaceHistories,
23
+ pullWorkspace,
24
+ pushWorkspace
25
+ } from "./git.js";
26
+ export { generateModelDocumentation } from "./model-docs.js";
27
+ export { renderMarkdown } from "./markdown.js";
28
+ export {
29
+ completeObligationAction,
30
+ completeObligationEvent,
31
+ completeObligationOccurrence,
32
+ createObligationEvent,
33
+ planObligations
34
+ } from "./obligations.js";
35
+ export {
36
+ addCalendarDays,
37
+ calendarDayDifference,
38
+ calendarOccurrence,
39
+ calendarOccurrenceIndex,
40
+ nextCalendarOccurrence
41
+ } from "./recurrence.js";
42
+ export { searchResources, searchableValues } from "./search.js";
43
+ export { createFileGRCServer, serveWorkspace } from "./server.js";
44
+ export { createAppState } from "./state.js";
45
+ export { currentCalendarDate, formatCalendarDate, formatLocalDateTime } from "./time.js";
46
+ export { validateWorkspace } from "./validate.js";
47
+ export { indexResources, loadWorkspace } from "./workspace.js";
@@ -0,0 +1,123 @@
1
+ export function renderMarkdown(source = "") {
2
+ const lines = String(source).replace(/\r\n?/g, "\n").split("\n");
3
+ const output = [];
4
+ let paragraph = [];
5
+ let list = null;
6
+ let code = null;
7
+ let table = null;
8
+
9
+ const flushParagraph = () => {
10
+ if (!paragraph.length) return;
11
+ output.push(`<p>${inline(paragraph.join(" "))}</p>`);
12
+ paragraph = [];
13
+ };
14
+ const flushList = () => {
15
+ if (!list) return;
16
+ output.push(`<${list.kind}>${list.items.map((item) => `<li>${inline(item)}</li>`).join("")}</${list.kind}>`);
17
+ list = null;
18
+ };
19
+ const flushTable = () => {
20
+ if (!table) return;
21
+ const [head, ...rows] = table;
22
+ output.push(`<div class="table-wrap"><table><thead><tr>${head.map((cell) => `<th>${inline(cell)}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${inline(cell)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
23
+ table = null;
24
+ };
25
+ const flush = () => {
26
+ flushParagraph();
27
+ flushList();
28
+ flushTable();
29
+ };
30
+
31
+ for (let index = 0; index < lines.length; index += 1) {
32
+ const line = lines[index];
33
+ if (code) {
34
+ if (line.startsWith("```")) {
35
+ output.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
36
+ code = null;
37
+ } else {
38
+ code.push(line);
39
+ }
40
+ continue;
41
+ }
42
+ if (line.startsWith("```")) {
43
+ flush();
44
+ code = [];
45
+ continue;
46
+ }
47
+ const heading = /^(#{1,6})\s+(.+)$/.exec(line);
48
+ if (heading) {
49
+ flush();
50
+ const level = heading[1].length;
51
+ output.push(`<h${level}>${inline(heading[2])}</h${level}>`);
52
+ continue;
53
+ }
54
+ if (/^>\s?/.test(line)) {
55
+ flush();
56
+ output.push(`<blockquote>${inline(line.replace(/^>\s?/, ""))}</blockquote>`);
57
+ continue;
58
+ }
59
+ if (/^[-*_]{3,}\s*$/.test(line)) {
60
+ flush();
61
+ output.push("<hr>");
62
+ continue;
63
+ }
64
+ const unordered = /^\s*[-*]\s+(.+)$/.exec(line);
65
+ const ordered = /^\s*\d+[.)]\s+(.+)$/.exec(line);
66
+ if (unordered || ordered) {
67
+ flushParagraph();
68
+ flushTable();
69
+ const kind = ordered ? "ol" : "ul";
70
+ if (list && list.kind !== kind) flushList();
71
+ list ??= { kind, items: [] };
72
+ list.items.push((unordered ?? ordered)[1]);
73
+ continue;
74
+ }
75
+ if (line.includes("|") && index + 1 < lines.length && isTableDivider(lines[index + 1])) {
76
+ flush();
77
+ table = [parseTableRow(line)];
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (table && line.includes("|")) {
82
+ table.push(parseTableRow(line));
83
+ continue;
84
+ }
85
+ if (!line.trim()) {
86
+ flush();
87
+ continue;
88
+ }
89
+ paragraph.push(line.trim());
90
+ }
91
+ flush();
92
+ if (code) output.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
93
+ return output.join("\n");
94
+ }
95
+
96
+ function inline(source) {
97
+ let value = escapeHtml(source);
98
+ value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
99
+ value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
100
+ value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
101
+ value = value.replace(/\[([^\]]+)]\(([^)\s]+)\)/g, (_, label, target) => {
102
+ if (!/^(?:https?:|mailto:|#)/.test(target)) return label;
103
+ return `<a href="${target.replaceAll("`", "&#96;")}" rel="noreferrer">${label}</a>`;
104
+ });
105
+ return value;
106
+ }
107
+
108
+ function parseTableRow(line) {
109
+ return line.replace(/^\s*\||\|\s*$/g, "").split("|").map((cell) => cell.trim());
110
+ }
111
+
112
+ function isTableDivider(line) {
113
+ return /^\s*\|?\s*:?-{3,}/.test(line) && line.includes("|");
114
+ }
115
+
116
+ export function escapeHtml(value) {
117
+ return String(value)
118
+ .replaceAll("&", "&amp;")
119
+ .replaceAll("<", "&lt;")
120
+ .replaceAll(">", "&gt;")
121
+ .replaceAll('"', "&quot;")
122
+ .replaceAll("'", "&#39;");
123
+ }
@@ -0,0 +1,119 @@
1
+ export function generateModelDocumentation(model) {
2
+ const lines = [
3
+ "# GRC Data Model",
4
+ "",
5
+ `<!-- Generated from packages/filegrc/model/v${model.modelVersion}.json. Do not edit by hand. -->`,
6
+ "",
7
+ `Model version: \`${model.modelVersion}\``,
8
+ "",
9
+ model.description,
10
+ "",
11
+ "Each structured resource is one UTF-8 JSON file. Long-form work is an implicit Markdown companion beside that JSON file. Git supplies file authors, timestamps, diffs, commit messages, and revisions, so records do not duplicate those fields or file paths.",
12
+ "",
13
+ "## Common fields",
14
+ "",
15
+ "| Field | Type | Required | Meaning |",
16
+ "| --- | --- | --- | --- |"
17
+ ];
18
+ for (const [name, field] of Object.entries(model.commonFields)) {
19
+ lines.push(`| \`${name}\` | ${fieldType(field)} | ${field.required ? "Yes" : "No"} | ${escapeCell(fieldNotes(field))} |`);
20
+ }
21
+ lines.push(
22
+ "",
23
+ "## Record Markdown",
24
+ "",
25
+ "Resources with no dedicated Markdown use an optional companion with the same basename as the JSON record. The renderer creates and discovers this file from the stable record location, so no path is stored in the record.",
26
+ "",
27
+ `Record Markdown is shown by default for: ${model.recordContent.defaultResourceTypes.map((type) => `\`${type}\``).join(", ")}. Other resources without dedicated Markdown can add it when structured fields are not enough.`,
28
+ "",
29
+ "## Audit preparation defaults",
30
+ "",
31
+ "The engine and renderer use these model-owned defaults to prepare Type 1 and Type 2 engagements. Preparation creates engagement-specific management documents from the local starter templates. Management still confirms scope, approves documents, catalogs authoritative source systems, reconciles Type 2 populations, and supplies source evidence.",
32
+ "",
33
+ "Management documents:",
34
+ "",
35
+ ...model.auditReadiness.managementDocuments.map((item) => `- **${item.title}** (\`${item.kind}\`): ${item.timing}`),
36
+ "",
37
+ "Standard populations, including zero-event populations:",
38
+ "",
39
+ ...model.auditReadiness.populationTemplates.map((item) => `- **${item.title}** (\`${item.kind}\`): source role \`${item.sourceKind}\`; start with ${item.sourcePrompt}. ${item.timing}`),
40
+ "",
41
+ "Authoritative systems of record:",
42
+ "",
43
+ ...model.auditReadiness.externalEvidence.map((item) => `- **${item.title}** (${item.sourceKinds.map((kind) => `\`${kind}\``).join(", ")}): ${item.description} ${item.timing}`),
44
+ "",
45
+ "## Resource groups",
46
+ ""
47
+ );
48
+
49
+ for (const group of model.groups) {
50
+ const resources = Object.entries(model.resources).filter(([, resource]) => resource.group === group.id);
51
+ if (!resources.length) continue;
52
+ lines.push(`### ${group.title}`, "");
53
+ for (const [type, resource] of resources) {
54
+ lines.push(`#### \`${type}\``, "", resource.description, "");
55
+ lines.push(`Policy basis: ${resource.guidance.policyBasis}`, "");
56
+ lines.push(`Timing: ${resource.guidance.cadence}`, "");
57
+ if (resource.guidance.sourceResourceIds?.length) {
58
+ lines.push(`Default sources: ${resource.guidance.sourceResourceIds.map((id) => `\`${id}\``).join(", ")}`, "");
59
+ }
60
+ if (resource.titleLabel) lines.push(`The UI labels the common \`title\` field as **${resource.titleLabel}**.`, "");
61
+ const recordPath = (resource.recordPath ?? "{id}.json").replaceAll("{id}", "<id>");
62
+ lines.push(`Path: \`${resource.singleton ? `data/${resource.singleton}` : `data/${resource.collection}/${recordPath}`}\``, "");
63
+ const contentMode = recordContentMode(model, type, resource);
64
+ if (contentMode) {
65
+ lines.push(`Record Markdown: ${contentMode === "default" ? "shown by default" : "available when needed"} as an implicit companion file.`, "");
66
+ }
67
+ if (resource.markdown) {
68
+ lines.push("Markdown companions:", "");
69
+ for (const [name, markdown] of Object.entries(resource.markdown)) {
70
+ const suffix = markdown.primary ? ".md" : `-${name}.md`;
71
+ lines.push(`- **${markdown.label}**: \`${suffix}\` beside the JSON record${markdown.required ? " (required)" : " (optional)"}.`);
72
+ }
73
+ lines.push("");
74
+ }
75
+ lines.push("| Field | Type | Required | Notes |", "| --- | --- | --- | --- |");
76
+ const required = new Set(resource.required ?? []);
77
+ for (const [name, field] of Object.entries(resource.fields ?? {})) {
78
+ const requiredLabel = required.has(name) || field.required ? "Yes" : field.requiredWhen ? "Conditional" : "No";
79
+ lines.push(`| \`${name}\` | ${fieldType(field)} | ${requiredLabel} | ${escapeCell(fieldNotes(field))} |`);
80
+ }
81
+ for (const choices of resource.oneOf ?? []) {
82
+ lines.push("", `At least one of ${choices.map(choiceLabel).join(", ")} is required.`);
83
+ }
84
+ lines.push("");
85
+ }
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ function recordContentMode(model, type, resource) {
91
+ if (!model.recordContent?.slot || resource.markdown) return null;
92
+ return model.recordContent.defaultResourceTypes.includes(type) ? "default" : "optional";
93
+ }
94
+
95
+ function choiceLabel(name) {
96
+ if (name.startsWith("$markdown:")) return `**${name.slice("$markdown:".length)} Markdown**`;
97
+ return `\`${name}\``;
98
+ }
99
+
100
+ function fieldType(field) {
101
+ if (field.type === "array") return `array of ${field.items ?? "values"}`;
102
+ return field.format && field.format !== field.type ? `${field.type} (${field.format})` : field.type;
103
+ }
104
+
105
+ function fieldNotes(field) {
106
+ return [
107
+ field.label,
108
+ field.values ? `Values: ${field.values.map((item) => `\`${item}\``).join(", ")}` : "",
109
+ field.relation ? `References: ${field.relation.map((item) => `\`${item}\``).join(", ")}` : "",
110
+ field.minimum !== undefined ? `Minimum: \`${field.minimum}\`.` : "",
111
+ field.maximum !== undefined ? `Maximum: \`${field.maximum}\`.` : "",
112
+ field.disjointFrom ? `Must not overlap \`${field.disjointFrom}\`.` : "",
113
+ field.requiredWhen ? `Required when ${Object.entries(field.requiredWhen).map(([key, value]) => `\`${key}\` is \`${value}\``).join(" and ")}` : ""
114
+ ].filter(Boolean).join(" ");
115
+ }
116
+
117
+ function escapeCell(value) {
118
+ return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
119
+ }
@@ -0,0 +1,15 @@
1
+ import { resolveWorkspaceRoot } from "./paths.js";
2
+
3
+ const mutationQueues = new Map();
4
+
5
+ export function serializeWorkspaceMutation(input, task) {
6
+ const root = resolveWorkspaceRoot(input);
7
+ const previous = mutationQueues.get(root) ?? Promise.resolve();
8
+ const run = previous.catch(() => {}).then(() => task(root));
9
+ let tracked;
10
+ tracked = run.finally(() => {
11
+ if (mutationQueues.get(root) === tracked) mutationQueues.delete(root);
12
+ });
13
+ mutationQueues.set(root, tracked);
14
+ return tracked;
15
+ }