@saadnvd1/agent-os 0.3.539 → 0.3.542
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 +25 -1994
- package/components/SessionList/SessionList.types.ts +1 -0
- package/components/SessionList/SessionShelves.tsx +8 -1
- package/components/SessionList/SidebarBody.tsx +1 -0
- package/components/SessionList/index.tsx +2 -1
- package/lib/db/queries.ts +1 -1
- package/lib/orchestrator/events.ts +1 -0
- package/lib/recipes/settle.ts +1 -1
- package/lib/security/auth.ts +1 -1
- package/lib/sidebar/machines.ts +7 -1
- package/lib/sidebar/rollup.ts +1 -0
- package/lib/sidebar/shelves.ts +38 -4
- package/lib/status/collect.ts +5 -1
- package/package.json +1 -1
- package/scripts/docs-links.test.ts +160 -0
|
@@ -86,7 +86,7 @@ export function MachineShelves({ groups }: { groups: MachineGroup[] }) {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// The pinned orchestrator (or a row to start one), then Pinned, Needs you,
|
|
89
|
-
// Working, Queued and Done: once for the whole list.
|
|
89
|
+
// Working, In review, Queued and Done: once for the whole list.
|
|
90
90
|
export function SessionShelves({
|
|
91
91
|
shelves,
|
|
92
92
|
queued,
|
|
@@ -136,6 +136,13 @@ export function SessionShelves({
|
|
|
136
136
|
))}
|
|
137
137
|
</Shelf>
|
|
138
138
|
)}
|
|
139
|
+
{shelves.inReview.length > 0 && (
|
|
140
|
+
<Shelf label="In review" count={shelves.inReview.length}>
|
|
141
|
+
{shelves.inReview.map((r) => (
|
|
142
|
+
<SessionRow key={r.session.id} row={r} />
|
|
143
|
+
))}
|
|
144
|
+
</Shelf>
|
|
145
|
+
)}
|
|
139
146
|
{queued.length > 0 && (
|
|
140
147
|
<Shelf label="Queued" count={queued.length}>
|
|
141
148
|
{queued.map((r) => (
|
|
@@ -31,7 +31,7 @@ const flatten = (rows: SidebarRow[]): string[] =>
|
|
|
31
31
|
rows.flatMap((r) => [r.session.id, ...flatten(r.workers)]);
|
|
32
32
|
|
|
33
33
|
// One flat list of the workspace's sessions, on shelves: Pinned, Needs
|
|
34
|
-
// you, Working, Done.
|
|
34
|
+
// you, Working, In review, Done.
|
|
35
35
|
export function SessionList({
|
|
36
36
|
activeSessionId,
|
|
37
37
|
sessionStatuses,
|
|
@@ -62,6 +62,7 @@ export function SessionList({
|
|
|
62
62
|
...shelves.pinned,
|
|
63
63
|
...shelves.needsYou,
|
|
64
64
|
...shelves.working,
|
|
65
|
+
...shelves.inReview,
|
|
65
66
|
...shelves.done,
|
|
66
67
|
...data.machines.flatMap((g) => g.rows),
|
|
67
68
|
]),
|
package/lib/db/queries.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function sessionListColumns(db: Database.Database, alias = ""): string {
|
|
|
33
33
|
.map((c) => `${prefix}${c}`)
|
|
34
34
|
.join(", ");
|
|
35
35
|
// The recipe run or feature piece a session is, as JSON, for its row's chip.
|
|
36
|
-
const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
|
|
36
|
+
const recipe = `(SELECT json_object('id', r.id, 'name', r.name, 'seq', rr.seq, 'status', rr.status) FROM recipe_runs rr JOIN recipes r ON r.id = rr.recipe_id WHERE rr.session_id = ${alias || "sessions"}.id)`;
|
|
37
37
|
const piece = `(SELECT json_object('feature', f.id, 'name', f.name, 'position', fp.position, 'total', (SELECT COUNT(*) FROM feature_pieces WHERE feature_id = f.id AND status != 'dropped')) FROM feature_pieces fp JOIN features f ON f.id = fp.feature_id WHERE fp.session_id = ${alias || "sessions"}.id)`;
|
|
38
38
|
return `${list}, (${prefix}task_brief IS NOT NULL AND ${prefix}task_brief != '') AS has_brief, ${recipe} AS recipe_run, ${piece} AS feature_piece`;
|
|
39
39
|
}
|
|
@@ -148,6 +148,7 @@ export function recordConditions(
|
|
|
148
148
|
db.prepare(
|
|
149
149
|
`DELETE FROM orchestrator_events WHERE workspace_id = ? AND (
|
|
150
150
|
(subject IS NOT NULL
|
|
151
|
+
AND NOT (key LIKE '${ONCE}%' AND delivered_at IS NULL)
|
|
151
152
|
AND subject NOT IN (SELECT id FROM sessions)
|
|
152
153
|
AND subject NOT IN (SELECT id FROM stack_items)
|
|
153
154
|
AND subject NOT IN (SELECT id FROM stacks))
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -194,7 +194,7 @@ function tell(
|
|
|
194
194
|
queueEvent(
|
|
195
195
|
run.recipe.workspace_id,
|
|
196
196
|
`recipe:${run.id}:${status}`,
|
|
197
|
-
run.
|
|
197
|
+
run.session_id,
|
|
198
198
|
`Recipe "${run.recipe.name}" run #${run.seq} ${bad ? status.replace("_", " ") : "finished ok"}${reported ? `: ${untrusted(run.recipe.name, reported)}` : "."}${pr}`
|
|
199
199
|
);
|
|
200
200
|
}
|
package/lib/security/auth.ts
CHANGED
|
@@ -54,7 +54,7 @@ const TAILSCALE_V4 = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./;
|
|
|
54
54
|
const LOOPBACK = new Set(["127.0.0.1", "::1"]);
|
|
55
55
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
56
56
|
// A proxy must send one of these, or it looks like this machine itself.
|
|
57
|
-
// Proxies that send none need AGENTOS_AUTH, see
|
|
57
|
+
// Proxies that send none need AGENTOS_AUTH, see docs/reference/security.md.
|
|
58
58
|
const FORWARDING = new Set([
|
|
59
59
|
"forwarded",
|
|
60
60
|
"via",
|
package/lib/sidebar/machines.ts
CHANGED
|
@@ -40,7 +40,13 @@ export function machineGroups(
|
|
|
40
40
|
return {
|
|
41
41
|
hostId,
|
|
42
42
|
name,
|
|
43
|
-
rows: [
|
|
43
|
+
rows: [
|
|
44
|
+
...s.pinned,
|
|
45
|
+
...s.needsYou,
|
|
46
|
+
...s.working,
|
|
47
|
+
...s.inReview,
|
|
48
|
+
...s.done,
|
|
49
|
+
],
|
|
44
50
|
};
|
|
45
51
|
})
|
|
46
52
|
.filter((g) => g.rows.length > 0)
|
package/lib/sidebar/rollup.ts
CHANGED
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// The sidebar's one flat list, sorted onto shelves: the workspace's pinned
|
|
2
|
-
// orchestrator, Pinned, Needs you, Working, then Done. Client-safe: no database.
|
|
2
|
+
// orchestrator, Pinned, Needs you, Working, In review, then Done. Client-safe: no database.
|
|
3
3
|
import type { Session } from "@/lib/db/types";
|
|
4
4
|
import { fromSqliteTime } from "@/lib/session-meta";
|
|
5
5
|
import type { PrStage } from "@/lib/tasks/pr-stage";
|
|
6
|
-
import type
|
|
6
|
+
import { isFinished, type TaskState } from "@/lib/tasks/state";
|
|
7
7
|
|
|
8
8
|
// Why a session is blocked on you.
|
|
9
9
|
export type SessionNeed =
|
|
@@ -33,6 +33,8 @@ export interface RowStatus {
|
|
|
33
33
|
stalled?: boolean;
|
|
34
34
|
// A chat summarising its context (status stays "running").
|
|
35
35
|
compacting?: { since: number; before?: number };
|
|
36
|
+
// Background commands, subagents and monitors still running.
|
|
37
|
+
background?: number;
|
|
36
38
|
asks?: number;
|
|
37
39
|
prStage?: PrStage;
|
|
38
40
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
@@ -61,6 +63,7 @@ export interface Shelves {
|
|
|
61
63
|
pinned: SidebarRow[];
|
|
62
64
|
needsYou: SidebarRow[];
|
|
63
65
|
working: SidebarRow[];
|
|
66
|
+
inReview: SidebarRow[];
|
|
64
67
|
done: SidebarRow[];
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -70,6 +73,17 @@ export function taskNeed(state: TaskState | undefined): SessionNeed | null {
|
|
|
70
73
|
return null;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
// Where a row that needs nothing and isn't running goes: an open PR or a plan
|
|
77
|
+
// waiting on approval is in review, any other unfinished task is mid-job, and
|
|
78
|
+
// only finished work or a plain idle session is done.
|
|
79
|
+
export function quietShelf(
|
|
80
|
+
task: TaskState | undefined
|
|
81
|
+
): "inReview" | "working" | "done" {
|
|
82
|
+
if (task === "review" || task === "plan-ready") return "inReview";
|
|
83
|
+
if (!task || isFinished(task)) return "done";
|
|
84
|
+
return "working";
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
// An approval or a question beats what the task says; a terminal waiting
|
|
74
88
|
// for input or an errored chat only counts when the task has nothing.
|
|
75
89
|
export function needOf(
|
|
@@ -118,6 +132,20 @@ const newestFirst = (a: SidebarRow, b: SidebarRow) =>
|
|
|
118
132
|
const oldestFirst = (a: SidebarRow, b: SidebarRow) =>
|
|
119
133
|
a.session.created_at.localeCompare(b.session.created_at);
|
|
120
134
|
|
|
135
|
+
const OPEN_RUN = ["queued", "starting", "running"];
|
|
136
|
+
|
|
137
|
+
// A recipe run that hasn't reported its result yet: its session going quiet
|
|
138
|
+
// between turns (an EAS build running in the background, say) isn't it ending.
|
|
139
|
+
// The list query carries the run as JSON.
|
|
140
|
+
export function recipeRunOpen(session: Pick<Session, "recipe_run">): boolean {
|
|
141
|
+
if (!session.recipe_run) return false;
|
|
142
|
+
try {
|
|
143
|
+
return OPEN_RUN.includes(JSON.parse(session.recipe_run)?.status);
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
122
150
|
const status = input.statuses[session.id];
|
|
123
151
|
return {
|
|
@@ -125,7 +153,10 @@ function toRow(input: ShelfInput, session: Session): SidebarRow {
|
|
|
125
153
|
status,
|
|
126
154
|
need: needOf(session, status, input.tasks[session.id]),
|
|
127
155
|
unread: !!status?.unread,
|
|
128
|
-
working:
|
|
156
|
+
working:
|
|
157
|
+
status?.status === "running" ||
|
|
158
|
+
(status?.background ?? 0) > 0 ||
|
|
159
|
+
recipeRunOpen(session),
|
|
129
160
|
compacting: !!status?.compacting,
|
|
130
161
|
workers: [],
|
|
131
162
|
task: input.tasks[session.id],
|
|
@@ -169,6 +200,7 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
169
200
|
pinned: [],
|
|
170
201
|
needsYou: [],
|
|
171
202
|
working: [],
|
|
203
|
+
inReview: [],
|
|
172
204
|
done: [],
|
|
173
205
|
};
|
|
174
206
|
for (const row of top) {
|
|
@@ -178,12 +210,13 @@ export function buildShelves(input: ShelfInput): Shelves {
|
|
|
178
210
|
else if (pinned && role !== "orchestrator") shelves.pinned.push(row);
|
|
179
211
|
else if (row.need) shelves.needsYou.push(row);
|
|
180
212
|
else if (row.working) shelves.working.push(row);
|
|
181
|
-
else shelves.
|
|
213
|
+
else shelves[quietShelf(row.task)].push(row);
|
|
182
214
|
}
|
|
183
215
|
shelves.orchestrators.sort(oldestFirst);
|
|
184
216
|
shelves.pinned.sort(oldestFirst);
|
|
185
217
|
shelves.needsYou.sort(newestFirst);
|
|
186
218
|
shelves.working.sort(newestFirst);
|
|
219
|
+
shelves.inReview.sort(newestFirst);
|
|
187
220
|
shelves.done.sort(newestFirst);
|
|
188
221
|
return shelves;
|
|
189
222
|
}
|
|
@@ -265,6 +298,7 @@ function sameStatus(a: RowStatus | undefined, b: RowStatus | undefined) {
|
|
|
265
298
|
!!a.stalled === !!b.stalled &&
|
|
266
299
|
(a.compacting?.since ?? null) === (b.compacting?.since ?? null) &&
|
|
267
300
|
(a.compacting?.before ?? null) === (b.compacting?.before ?? null) &&
|
|
301
|
+
(a.background ?? 0) === (b.background ?? 0) &&
|
|
268
302
|
a.asks === b.asks &&
|
|
269
303
|
(a.prStage?.stage ?? null) === (b.prStage?.stage ?? null) &&
|
|
270
304
|
(a.prStage?.number ?? null) === (b.prStage?.number ?? null) &&
|
package/lib/status/collect.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { noteWaiting } from "../notify/push/waiting";
|
|
|
26
26
|
import { getDb } from "../db";
|
|
27
27
|
import { beginPass, chatMemo } from "./incremental";
|
|
28
28
|
import { chatCompacting, chatState } from "../chat/runner";
|
|
29
|
-
import { chatActivityLine, chatStall } from "../chat/activity";
|
|
29
|
+
import { chatActivity, chatActivityLine, chatStall } from "../chat/activity";
|
|
30
30
|
import {
|
|
31
31
|
chatNeed,
|
|
32
32
|
isUnread,
|
|
@@ -72,6 +72,8 @@ export interface SessionStatusResponse {
|
|
|
72
72
|
stalled?: boolean;
|
|
73
73
|
// A chat whose agent is summarising its context ("status" stays running).
|
|
74
74
|
compacting?: Compacting;
|
|
75
|
+
// Background commands, subagents and monitors a chat still has running.
|
|
76
|
+
background?: number;
|
|
75
77
|
// Where a task's PR is in the gates (lib/tasks/pr-stage.ts).
|
|
76
78
|
prStage?: PrStage;
|
|
77
79
|
// A program's own message (OSC 7501): untrusted, plain text only.
|
|
@@ -474,6 +476,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
474
476
|
(s) => s.view === "chat" && (!s.host_id || s.host_id === "local")
|
|
475
477
|
)) {
|
|
476
478
|
const state = chatState(session.id);
|
|
479
|
+
const background = chatActivity(session.id)?.background;
|
|
477
480
|
const orchestrator = session.role === "orchestrator";
|
|
478
481
|
// What the database says; the rest below moves with time or the process.
|
|
479
482
|
const base = chatMemo(pass, session.id, orchestrator, state ?? "", () => {
|
|
@@ -498,6 +501,7 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
498
501
|
...(chatCompacting(session.id) && {
|
|
499
502
|
compacting: chatCompacting(session.id)!,
|
|
500
503
|
}),
|
|
504
|
+
...(background && { background }),
|
|
501
505
|
...(orchestrator && { asks: base.asks }),
|
|
502
506
|
};
|
|
503
507
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(__dirname, "..");
|
|
7
|
+
const REPO = "saadnvd1/agent-os";
|
|
8
|
+
|
|
9
|
+
function slug(heading: string): string {
|
|
10
|
+
return heading
|
|
11
|
+
.trim()
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[`*]/g, "")
|
|
14
|
+
.replace(/[^\w\- ]/g, "")
|
|
15
|
+
.replace(/ /g, "-");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function headingSlugs(text: string): Set<string> {
|
|
19
|
+
const seen = new Map<string, number>();
|
|
20
|
+
const out = new Set<string>();
|
|
21
|
+
let fence = false;
|
|
22
|
+
for (const line of text.split("\n")) {
|
|
23
|
+
if (line.startsWith("```")) fence = !fence;
|
|
24
|
+
const m = !fence && /^#+ (.*)$/.exec(line);
|
|
25
|
+
if (!m) continue;
|
|
26
|
+
const base = slug(m[1]);
|
|
27
|
+
const n = seen.get(base) ?? 0;
|
|
28
|
+
seen.set(base, n + 1);
|
|
29
|
+
out.add(n ? `${base}-${n}` : base);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function markdownFiles(): string[] {
|
|
35
|
+
const out = execFileSync(
|
|
36
|
+
"git",
|
|
37
|
+
["ls-files", "--", "README.md", "docs/*.md", "docs/**/*.md"],
|
|
38
|
+
{ cwd: root, encoding: "utf8" }
|
|
39
|
+
);
|
|
40
|
+
return out.split("\n").filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Link targets with their line numbers, skipping code. */
|
|
44
|
+
function targets(text: string): { line: number; target: string }[] {
|
|
45
|
+
const found: { line: number; target: string }[] = [];
|
|
46
|
+
let fence = false;
|
|
47
|
+
text.split("\n").forEach((raw, i) => {
|
|
48
|
+
if (raw.startsWith("```")) fence = !fence;
|
|
49
|
+
if (fence) return;
|
|
50
|
+
const line = raw.replace(/`[^`]*`/g, "");
|
|
51
|
+
for (const m of line.matchAll(/\]\(([^)\s]+)\)/g))
|
|
52
|
+
found.push({ line: i + 1, target: m[1] });
|
|
53
|
+
for (const m of line.matchAll(/(?:src|srcset)="([^"]+)"/g))
|
|
54
|
+
found.push({ line: i + 1, target: m[1] });
|
|
55
|
+
});
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A repo path inside a link to this repository on github.com, if it is one. */
|
|
60
|
+
function repoPath(target: string): string | null {
|
|
61
|
+
const m = new RegExp(
|
|
62
|
+
`^https://(?:github\\.com/${REPO}(?:/(?:blob|tree)/main)?|raw\\.githubusercontent\\.com/${REPO}/main)/?(.*)$`
|
|
63
|
+
).exec(target);
|
|
64
|
+
return m ? m[1] : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readTracked(rel: string): string | null {
|
|
68
|
+
const file = path.join(root, rel.replace(/\/$/, ""));
|
|
69
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return null;
|
|
70
|
+
return fs.readFileSync(file, "utf8");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("slug", () => {
|
|
74
|
+
it("follows GitHub's anchors", () => {
|
|
75
|
+
expect(slug("Project config (`agentos.json`)")).toBe(
|
|
76
|
+
"project-config-agentosjson"
|
|
77
|
+
);
|
|
78
|
+
expect(slug("Windows (WSL2)")).toBe("windows-wsl2");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("markdown docs", () => {
|
|
83
|
+
const files = markdownFiles();
|
|
84
|
+
|
|
85
|
+
it("have links that resolve", () => {
|
|
86
|
+
const broken: string[] = [];
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
const text = fs.readFileSync(path.join(root, file), "utf8");
|
|
89
|
+
const own = headingSlugs(text);
|
|
90
|
+
const inGuides = file.startsWith("docs/guides/");
|
|
91
|
+
for (const { line, target } of targets(text)) {
|
|
92
|
+
const at = `${file}:${line} -> ${target}`;
|
|
93
|
+
if (target.startsWith("mailto:")) continue;
|
|
94
|
+
let rel: string | null;
|
|
95
|
+
let anchor = "";
|
|
96
|
+
if (target.startsWith("#")) {
|
|
97
|
+
if (!own.has(target.slice(1))) broken.push(at);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (/^https?:/.test(target)) {
|
|
101
|
+
const hash = target.indexOf("#");
|
|
102
|
+
const url = hash < 0 ? target : target.slice(0, hash);
|
|
103
|
+
anchor = hash < 0 ? "" : target.slice(hash + 1);
|
|
104
|
+
rel = repoPath(url);
|
|
105
|
+
if (rel === null) continue;
|
|
106
|
+
if (rel === "") rel = "README.md";
|
|
107
|
+
} else {
|
|
108
|
+
const [p, a = ""] = target.split("#");
|
|
109
|
+
anchor = a;
|
|
110
|
+
rel = path.posix.normalize(
|
|
111
|
+
path.posix.join(path.posix.dirname(file), p)
|
|
112
|
+
);
|
|
113
|
+
if (
|
|
114
|
+
inGuides &&
|
|
115
|
+
!rel.startsWith("docs/guides/") &&
|
|
116
|
+
!p.startsWith("http")
|
|
117
|
+
)
|
|
118
|
+
broken.push(`${at} (guides are published alone; link by full URL)`);
|
|
119
|
+
}
|
|
120
|
+
const abs = path.join(root, rel.replace(/\/$/, ""));
|
|
121
|
+
if (!fs.existsSync(abs)) {
|
|
122
|
+
broken.push(at);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (anchor && rel.endsWith(".md")) {
|
|
126
|
+
const dest = readTracked(rel);
|
|
127
|
+
if (dest === null || !headingSlugs(dest).has(anchor)) broken.push(at);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
expect(broken).toEqual([]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("keep guide front matter complete, with unique order", () => {
|
|
135
|
+
const orders = new Map<string, string>();
|
|
136
|
+
const problems: string[] = [];
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
if (!file.startsWith("docs/guides/") || file.endsWith("README.md"))
|
|
139
|
+
continue;
|
|
140
|
+
const text = fs.readFileSync(path.join(root, file), "utf8");
|
|
141
|
+
const fm = /^---\n([\s\S]*?)\n---\n/.exec(text)?.[1] ?? "";
|
|
142
|
+
for (const key of ["title", "description"])
|
|
143
|
+
if (!new RegExp(`^${key}: .+`, "m").test(fm))
|
|
144
|
+
problems.push(`${file}: missing ${key}`);
|
|
145
|
+
const order = /^order: (\d+)$/m.exec(fm)?.[1];
|
|
146
|
+
if (!order) problems.push(`${file}: missing numeric order`);
|
|
147
|
+
else if (orders.has(order))
|
|
148
|
+
problems.push(`${file}: order ${order} also in ${orders.get(order)}`);
|
|
149
|
+
else orders.set(order, file);
|
|
150
|
+
}
|
|
151
|
+
expect(problems).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps the README a front page", () => {
|
|
155
|
+
const lines = fs
|
|
156
|
+
.readFileSync(path.join(root, "README.md"), "utf8")
|
|
157
|
+
.split("\n").length;
|
|
158
|
+
expect(lines).toBeLessThan(250);
|
|
159
|
+
});
|
|
160
|
+
});
|