@lernapps/tooling 0.3.1 → 0.5.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.
Files changed (44) hide show
  1. package/dist/check/rules/competencies-on-map.mjs +85 -0
  2. package/dist/check/rules/one-competency-per-page.mjs +18 -0
  3. package/dist/check/rules/prerequisites-linked.mjs +38 -0
  4. package/dist/cli.mjs +519 -56
  5. package/dist/explainer-map-DW4XCcY0.mjs +124 -0
  6. package/dist/skills/lernapps-app/SKILL.md +2 -1
  7. package/dist/skills/lernapps-cli/SKILL.md +13 -4
  8. package/dist/skills/lernapps-content-questions/SKILL.md +2 -0
  9. package/dist/skills/lernapps-content-questions/references/question-types.md +2 -1
  10. package/dist/skills/lernapps-content-research/SKILL.md +41 -5
  11. package/dist/skills/lernapps-exercise-patterns/references/mathematics.md +25 -1
  12. package/dist/skills/lernapps-exercise-patterns/references/physics.md +1 -0
  13. package/dist/skills/lernapps-explainer/SKILL.md +10 -4
  14. package/dist/skills/lernapps-explainer/references/blocks.md +7 -3
  15. package/dist/skills/lernapps-explainer/references/exercises.md +6 -0
  16. package/dist/skills/lernapps-explainer/references/rules.md +29 -0
  17. package/dist/skills/lernapps-explainer/references/structure.md +62 -0
  18. package/dist/skills/lernapps-explainer-design/SKILL.md +46 -2
  19. package/dist/skills/lernapps-explainer-design/references/rules.md +1 -0
  20. package/dist/skills/lernapps-explainer-self-assessment/SKILL.md +1 -0
  21. package/dist/skills/lernapps-listing/SKILL.md +9 -0
  22. package/generator/create.ts +39 -10
  23. package/generator/init.ts +89 -19
  24. package/generator/update.ts +53 -8
  25. package/guidance/increment-template.md +13 -5
  26. package/guidance/plan-template.md +25 -11
  27. package/guidance/workflow-rules.md +3 -1
  28. package/package.json +3 -3
  29. package/skills/archetypes/lernapps-explainer/SKILL.md +10 -4
  30. package/skills/archetypes/lernapps-explainer/references/blocks.md +7 -3
  31. package/skills/archetypes/lernapps-explainer/references/exercises.md +6 -0
  32. package/skills/archetypes/lernapps-explainer/references/rules.md +29 -0
  33. package/skills/archetypes/lernapps-explainer/references/structure.md +62 -0
  34. package/skills/archetypes/lernapps-explainer-design/SKILL.md +46 -2
  35. package/skills/archetypes/lernapps-explainer-design/references/rules.md +1 -0
  36. package/skills/archetypes/lernapps-explainer-self-assessment/SKILL.md +1 -0
  37. package/skills/content/lernapps-content-questions/SKILL.md +2 -0
  38. package/skills/content/lernapps-content-questions/references/question-types.md +2 -1
  39. package/skills/content/lernapps-content-research/SKILL.md +41 -5
  40. package/skills/content/lernapps-exercise-patterns/references/mathematics.md +25 -1
  41. package/skills/content/lernapps-exercise-patterns/references/physics.md +1 -0
  42. package/skills/process/lernapps-app/SKILL.md +2 -1
  43. package/skills/process/lernapps-listing/SKILL.md +9 -0
  44. package/skills/tooling/lernapps-cli/SKILL.md +13 -4
package/dist/cli.mjs CHANGED
@@ -5,14 +5,219 @@ import { a as countFailedChecks, c as planFrontMatter, i as PLAN_NAME, l as plan
5
5
  import { createRequire } from "node:module";
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { appendFileSync, closeSync, copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
8
- import { homedir, tmpdir } from "node:os";
8
+ import { homedir, hostname, tmpdir } from "node:os";
9
9
  import { basename, delimiter, dirname, extname, join, relative, resolve, sep } from "node:path";
10
10
  import { parseArgs } from "node:util";
11
11
  import { parse, stringify } from "yaml";
12
+ import { createHash } from "node:crypto";
12
13
  import { fileURLToPath, pathToFileURL } from "node:url";
13
14
  import { createServer } from "node:http";
14
15
  import { chromium } from "playwright";
15
- import { createHash } from "node:crypto";
16
+ /** Every package the lock file installs below node_modules, by its path: the version it asks for and whether it is optional. */
17
+ function lockedPackages(dir) {
18
+ const file = join(dir, "package-lock.json");
19
+ if (!existsSync(file)) return void 0;
20
+ const lock = JSON.parse(readFileSync(file, "utf8"));
21
+ return new Map(Object.entries(lock.packages ?? {}).filter(([path, entry]) => path.startsWith("node_modules/") && entry.link !== true));
22
+ }
23
+ /** The version of the package installed at `path` (below `dir`), or undefined when none is installed there. */
24
+ function installedVersion(dir, path) {
25
+ try {
26
+ const manifest = JSON.parse(readFileSync(join(dir, path, "package.json"), "utf8"));
27
+ return typeof manifest.version === "string" ? manifest.version : "";
28
+ } catch {
29
+ return;
30
+ }
31
+ }
32
+ /**
33
+ * The installed version of every package the lock file names, as one line per package ("path version", "path -" when
34
+ * none is installed), sorted: what node_modules holds, for the key of a cached result. Undefined without a lock file.
35
+ */
36
+ function installedPackages(dir) {
37
+ const locked = lockedPackages(dir);
38
+ if (locked === void 0) return void 0;
39
+ return [...locked.keys()].sort().map((path) => `${path} ${installedVersion(dir, path) ?? "-"}`).join("\n");
40
+ }
41
+ /** How many mismatches a finding names before it says how many more there are. */
42
+ const SHOWN = 5;
43
+ /**
44
+ * Whether node_modules holds what package-lock.json asks for: every package at its version. An optional package that
45
+ * is not installed (one for another platform) is no mismatch. Empty without a lock file or when everything matches;
46
+ * else one problem that names the first mismatches.
47
+ */
48
+ function installedProblems(dir) {
49
+ const locked = lockedPackages(dir);
50
+ if (locked === void 0 || locked.size === 0) return [];
51
+ const mismatches = [];
52
+ for (const [path, entry] of [...locked].sort(([a], [b]) => a.localeCompare(b))) {
53
+ const installed = installedVersion(dir, path);
54
+ if (installed === void 0 && entry.optional === true) continue;
55
+ if (installed !== void 0 && (entry.version === void 0 || installed === entry.version)) continue;
56
+ const name = path.slice(path.lastIndexOf("node_modules/") + 13);
57
+ mismatches.push(installed === void 0 ? `${name} is not installed (the lock file asks for ${entry.version ?? "it"})` : `${name} ${installed} is installed, the lock file asks for ${entry.version}`);
58
+ }
59
+ if (mismatches.length === 0) return [];
60
+ const more = mismatches.length > SHOWN ? ` and ${mismatches.length - SHOWN} more` : "";
61
+ return [{
62
+ where: "node_modules",
63
+ found: `node_modules does not hold what package-lock.json asks for: ${mismatches.slice(0, SHOWN).join("; ")}${more}. CI installs with npm ci, so the check would test other packages than CI`,
64
+ fix: "Run npm ci, which installs exactly what package-lock.json asks for, then check again"
65
+ }];
66
+ }
67
+ /** A warning when this Node has another major version than CI, or undefined. */
68
+ function nodeMajorProblem(version = process.versions.node) {
69
+ if (Number(version.split(".")[0]) === 26) return void 0;
70
+ return {
71
+ where: "node",
72
+ found: `Node ${version} runs this check, CI runs Node 26: what passes here may fail in CI`,
73
+ fix: `Use Node 26 (for example nvm install 26); the check does not fail for this`,
74
+ severity: "warning"
75
+ };
76
+ }
77
+ const git$3 = (cwd, ...args) => {
78
+ const result = spawnSync("git", args, {
79
+ cwd,
80
+ encoding: "utf8"
81
+ });
82
+ return result.status === 0 ? result.stdout : void 0;
83
+ };
84
+ /** The folder of the cache and the lock: <git common dir>/lernapps, or undefined outside a repository. */
85
+ function stateDir(cwd = ".") {
86
+ const common = git$3(cwd, "rev-parse", "--git-common-dir")?.trim();
87
+ return common === void 0 || common === "" ? void 0 : join(resolve(cwd, common), "lernapps");
88
+ }
89
+ /**
90
+ * The key of the state in `cwd` for a check of `parts`, or the reason it has none: not a repository, no commit, a
91
+ * working tree with changes git sees (untracked files included), or a run in CI.
92
+ */
93
+ function cacheKey(cwd, parts, toolingVersion, env = process.env) {
94
+ if (env["CI"] !== void 0 && env["CI"] !== "" && env["CI"] !== "false") return { none: "CI checks every time" };
95
+ const top = git$3(cwd, "rev-parse", "--show-toplevel")?.trim();
96
+ if (top === void 0) return { none: "not a git repository" };
97
+ const tree = git$3(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{tree}")?.trim();
98
+ if (tree === void 0 || tree === "") return { none: "no commit yet" };
99
+ const status = git$3(top, "status", "--porcelain", "--untracked-files=all");
100
+ if (status === void 0) return { none: "git status failed" };
101
+ if (status.trim() !== "") return { none: "the working tree has changes that are not committed" };
102
+ const variables = Object.keys(env).filter((name) => name.startsWith("LERNAPPS_")).sort().map((name) => `${name}=${env[name]}`);
103
+ const hash = createHash("sha256");
104
+ for (const part of [
105
+ `tree ${tree}`,
106
+ `folder ${relative(top, resolve(cwd)).split("\\").join("/")}`,
107
+ `parts ${[...parts].sort().join(",")}`,
108
+ `tooling ${toolingVersion}`,
109
+ `node ${process.versions.node}`,
110
+ ...variables,
111
+ `installed\n${installedPackages(cwd) ?? ""}`
112
+ ]) {
113
+ hash.update(part);
114
+ hash.update("\0");
115
+ }
116
+ return { key: hash.digest("hex") };
117
+ }
118
+ const checkedFile = (dir, key) => join(dir, "checked", `${key}.yaml`);
119
+ /** The report of a passing check of this state, and when it was made; undefined when there is none. */
120
+ function cachedReport(dir, key) {
121
+ const file = checkedFile(dir, key);
122
+ try {
123
+ return {
124
+ report: readFileSync(file, "utf8"),
125
+ at: statSync(file).mtime
126
+ };
127
+ } catch {
128
+ return;
129
+ }
130
+ }
131
+ /** Keeps the report of a passing check of this state; the cache keeps the newest KEEP states. */
132
+ function storeReport(dir, key, report) {
133
+ const folder = join(dir, "checked");
134
+ mkdirSync(folder, { recursive: true });
135
+ writeFileSync(checkedFile(dir, key), report);
136
+ const files = readdirSync(folder).filter((name) => name.endsWith(".yaml")).map((name) => ({
137
+ file: join(folder, name),
138
+ at: statSync(join(folder, name)).mtimeMs
139
+ })).sort((a, b) => b.at - a.at);
140
+ for (const { file } of files.slice(20)) rmSync(file, { force: true });
141
+ }
142
+ /** Whether a process with this id runs on this machine. */
143
+ function alive(pid) {
144
+ try {
145
+ process.kill(pid, 0);
146
+ return true;
147
+ } catch (error) {
148
+ return error.code === "EPERM";
149
+ }
150
+ }
151
+ function readOwner(lock) {
152
+ try {
153
+ return JSON.parse(readFileSync(join(lock, "owner.json"), "utf8"));
154
+ } catch {
155
+ return;
156
+ }
157
+ }
158
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
159
+ /**
160
+ * Takes the lock of checks in this clone (a folder, made atomically with mkdir), waiting while another check holds
161
+ * it; `waiting` is called once, with its owner, when it has to wait. A lock whose process no longer runs on this
162
+ * machine is taken over. Returns the function that releases it; the lock is also released when the process exits or
163
+ * is interrupted.
164
+ */
165
+ async function acquireLock(dir, waiting, poll = 1e3) {
166
+ mkdirSync(dir, { recursive: true });
167
+ const lock = join(dir, "check.lock");
168
+ let told = false;
169
+ for (;;) {
170
+ try {
171
+ mkdirSync(lock);
172
+ break;
173
+ } catch (error) {
174
+ if (error.code !== "EEXIST") throw error;
175
+ }
176
+ const owner = readOwner(lock);
177
+ if (owner !== void 0 && owner.host === hostname() && !alive(owner.pid)) {
178
+ rmSync(lock, {
179
+ recursive: true,
180
+ force: true
181
+ });
182
+ continue;
183
+ }
184
+ const made = statSync(lock, { throwIfNoEntry: false })?.mtimeMs;
185
+ if (owner === void 0 && made !== void 0 && Date.now() - made > 10 * poll) {
186
+ rmSync(lock, {
187
+ recursive: true,
188
+ force: true
189
+ });
190
+ continue;
191
+ }
192
+ if (!told) {
193
+ waiting(owner);
194
+ told = true;
195
+ }
196
+ await sleep(poll);
197
+ }
198
+ writeFileSync(join(lock, "owner.json"), JSON.stringify({
199
+ pid: process.pid,
200
+ host: hostname(),
201
+ at: (/* @__PURE__ */ new Date()).toISOString()
202
+ }));
203
+ const release = () => {
204
+ process.off("exit", release);
205
+ process.off("SIGINT", interrupted);
206
+ process.off("SIGTERM", interrupted);
207
+ if (readOwner(lock)?.pid === process.pid) rmSync(lock, {
208
+ recursive: true,
209
+ force: true
210
+ });
211
+ };
212
+ const interrupted = (signal) => {
213
+ release();
214
+ process.exit(signal === "SIGINT" ? 130 : 143);
215
+ };
216
+ process.on("exit", release);
217
+ process.on("SIGINT", interrupted);
218
+ process.on("SIGTERM", interrupted);
219
+ return release;
220
+ }
16
221
  // check/run.ts
17
222
  const RULE_PAGE = "https://lernapps.net/tooling/rules/#";
18
223
  const finding = (rule, severity, problem) => ({
@@ -644,6 +849,10 @@ const CHECK_OPTIONS = {
644
849
  type: "boolean",
645
850
  default: false
646
851
  },
852
+ "no-cache": {
853
+ type: "boolean",
854
+ default: false
855
+ },
647
856
  entry: { type: "string" },
648
857
  report: { type: "string" },
649
858
  help: {
@@ -652,7 +861,7 @@ const CHECK_OPTIONS = {
652
861
  default: false
653
862
  }
654
863
  };
655
- const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [<dir|url>] [--entry <file>] [--report <file>]
864
+ const CHECK_USAGE = `Usage: lernapps check [--pre-commit] [--pre-push] [--no-cache] [<dir|url>] [--entry <file>] [--report <file>]
656
865
 
657
866
  Runs every check that has something to check. Passing, it prints one line; otherwise the validation report in
658
867
  YAML (schema: https://lernapps.net/tooling/schemas/validation-report.v1.schema.json). Every finding names its
@@ -669,7 +878,9 @@ In a repo (no <dir|url>):
669
878
  .arc42ignore does not list; a hint to review an element under whose path code changed is a
670
879
  warning. Before the first commit arc42 validate --strict
671
880
  --pre-push the heavy part: in an app with an architecture arc42 validate --strict, unit tests, build (vp build for an app with index.html, else vp pack),
672
- checks of the built app in dist/, the Playwright tests when playwright.config.* exists,
881
+ checks of the built app in dist/, of an explainer in format 2 its strand, page
882
+ competencies, recap pages and prerequisites of other strands against the learning map
883
+ (https://lernapps.net/apps/map/), the Playwright tests when playwright.config.* exists,
673
884
  dependency licences, the lock file (the pre-push hook); a failure increments prePushFailures in the front
674
885
  matter of the current plan (the highest number in .vibe/plans/, else .vibe/plan.md). A
675
886
  failing generic test of the archetype's runtime (@lernapps/app-templates) is named as a test
@@ -681,8 +892,20 @@ In a repo (no <dir|url>):
681
892
  checks the site in a repo with one (npm run build, then npm run check:site in the tooling, npm run check in a
682
893
  site). Then it prints one line and writes no report. Anything else, or changed files it cannot know, runs the
683
894
  part. CI decides the same in the app check action (lernapps changes --help).
895
+ Every run in a repo first compares node_modules with package-lock.json: a package installed at another
896
+ version, or missing, fails the run before anything else (run npm ci), since CI installs with npm ci.
897
+ The heavy part (--pre-push, or both) then:
898
+ - reuses a passing result of the same state: the tree of HEAD, the folder, the parts, the versions of
899
+ the tooling and of Node, the packages installed and every LERNAPPS_* variable, with a working tree
900
+ that has no changes git sees (untracked files included). It prints that line with "already checked".
901
+ Kept in <git common dir>/lernapps/, which the worktrees of a clone share; never in CI (CI set)
902
+ - waits for another check of the heavy part in this clone (and its worktrees), saying so in one line
903
+ on standard error; a lock whose process no longer runs is taken over
904
+ - warns in one line on standard error when Node has another major version than CI (no finding:
905
+ the report is the same for the same state wherever it is checked)
684
906
  Every run in a repo also writes the full report to node_modules/.cache/lernapps/validation-report.yaml,
685
- passing or not; the app check action keeps it. A failed run counts each rule with an error in failedChecks
907
+ passing or not; the app check action keeps it. A run that checked writes how long each step took to
908
+ node_modules/.cache/lernapps/timings.json; the passing line ends with the whole time. A failed run counts each rule with an error in failedChecks
686
909
  of the current plan's front matter, when it has the key.
687
910
 
688
911
  On a built app, without its repo:
@@ -712,6 +935,7 @@ Options:
712
935
  #<section> (or #<section>/<sub>) is an element id of its page as the server sends it, the
713
936
  fitness values match; without <dir|url> the app at the entry's url is checked
714
937
  --report <file> also write the full report to <file>, passing or not
938
+ --no-cache check again even when this state passed before (in a repo)
715
939
  -h, --help print this help
716
940
 
717
941
  `;
@@ -829,8 +1053,9 @@ details of a figure, such as where a line of a drawing points, in its own image.
829
1053
  send images: the images are PNG files, and an index.html shows them all in page order with each page's title and path
830
1054
  (one file, images relative, no network).
831
1055
 
832
- The built app is <dir>, default dist/ (build it first). Its pages are those lernapps check visits; the start page
833
- comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
1056
+ The built app is <dir> (build it first); default dist/ (npx vp build, as lernapps check builds) or _site/ (npm run
1057
+ build), whichever has an index.html, the newer when both have one. Its pages are those lernapps check visits; the
1058
+ start page comes first. Each is opened in Chromium, as a learner first sees it: nothing answered, no hover, animations off,
834
1059
  fonts and images loaded; requests to other hosts are blocked. A figure is a block figure or labelled-figure, or any
835
1060
  other figure with an image or a drawing. It is cut out with its caption, at twice its size on screen, from the page
836
1061
  as the build writes it, without JavaScript, so that the figures of every question of a quiz are there too; a figure
@@ -1096,24 +1321,24 @@ function loadRules(dir) {
1096
1321
  };
1097
1322
  }
1098
1323
  const ZERO = /^0+$/;
1099
- const git$1 = (cwd, ...args) => spawnSync("git", args, {
1324
+ const git$2 = (cwd, ...args) => spawnSync("git", args, {
1100
1325
  cwd,
1101
1326
  encoding: "utf8",
1102
1327
  maxBuffer: 67108864
1103
1328
  });
1104
1329
  /** The paths of `git diff --name-only -z`; renames as a deletion and an addition, so both paths count. */
1105
1330
  function diffNames(cwd, ...args) {
1106
- const diff = git$1(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
1331
+ const diff = git$2(cwd, "diff", "--name-only", "--no-renames", "-z", ...args);
1107
1332
  if (diff.error || diff.status !== 0) return void 0;
1108
1333
  return diff.stdout.split("\0").filter((path) => path !== "");
1109
1334
  }
1110
- const isCommit = (cwd, rev) => git$1(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
1335
+ const isCommit = (cwd, rev) => git$2(cwd, "cat-file", "-e", `${rev}^{commit}`).status === 0;
1111
1336
  /**
1112
1337
  * What a new branch is compared with: its merge base with origin/main; when the remote has no main yet (the first push
1113
1338
  * of a new repo), the empty tree, so the branch adds everything it holds (nothing, for an empty start commit).
1114
1339
  */
1115
1340
  function newBranchBase(cwd, local) {
1116
- if (git$1(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
1341
+ if (git$2(cwd, "rev-parse", "--verify", "--quiet", "origin/main").status !== 0) {
1117
1342
  const empty = spawnSync("git", [
1118
1343
  "hash-object",
1119
1344
  "-t",
@@ -1130,7 +1355,7 @@ function newBranchBase(cwd, local) {
1130
1355
  };
1131
1356
  }
1132
1357
  return {
1133
- base: git$1(cwd, "merge-base", local, "origin/main").stdout.trim(),
1358
+ base: git$2(cwd, "merge-base", local, "origin/main").stdout.trim(),
1134
1359
  tree: false
1135
1360
  };
1136
1361
  }
@@ -1163,8 +1388,8 @@ function pushedFiles(lines, cwd = ".") {
1163
1388
  }
1164
1389
  /** Fetches the whole history once, when the clone is shallow (as actions/checkout makes it by default). */
1165
1390
  function unshallow(cwd) {
1166
- if (git$1(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
1167
- return git$1(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
1391
+ if (git$2(cwd, "rev-parse", "--is-shallow-repository").stdout.trim() !== "true") return false;
1392
+ return git$2(cwd, "fetch", "--quiet", "--no-tags", "--unshallow", "origin").status === 0;
1168
1393
  }
1169
1394
  /**
1170
1395
  * The files between two commits: with `mergeBase`, what `head` adds to its merge base with `base` (a pull request);
@@ -1174,7 +1399,7 @@ function unshallow(cwd) {
1174
1399
  function rangeFiles(base, head, mergeBase, cwd = ".") {
1175
1400
  const attempt = () => {
1176
1401
  if (!isCommit(cwd, base) || !isCommit(cwd, head)) return void 0;
1177
- const from = mergeBase ? git$1(cwd, "merge-base", base, head).stdout.trim() : base;
1402
+ const from = mergeBase ? git$2(cwd, "merge-base", base, head).stdout.trim() : base;
1178
1403
  return from === "" ? void 0 : diffNames(cwd, from, head);
1179
1404
  };
1180
1405
  return attempt() ?? (unshallow(cwd) ? attempt() : void 0);
@@ -1436,7 +1661,8 @@ node_modules init wrote). From the installed package @lernapps/app-templates, th
1436
1661
  on, it writes the archetype's scaffold: the files of the template folder <name>/ that the package's lernapps.scaffold
1437
1662
  lists (the entry, the configuration, the end-to-end tests of the content) and a placeholder as start content
1438
1663
  (scaffold/<name>/), which the planned content replaces. The template folder itself is the example app: it stays in
1439
- node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version.
1664
+ node_modules/@lernapps/app-templates/<name>/, to look up. The app depends on the runtime of the same version (or
1665
+ the same commit, while @lernapps/tooling depends on an unreleased one).
1440
1666
 
1441
1667
  It needs the plan of the first version, confirmed by the creator: started from the plan template as
1442
1668
  .vibe/plans/01-<short name>.md, the only plan there, worked through Explore and Plan, now in the phase code
@@ -1461,7 +1687,10 @@ publish action to the repo's GitHub Pages), an issue form for content errors, LI
1461
1687
  Renovate preset of lernapps). The installed skills and skills-lock.json are committed with the app, like the
1462
1688
  content guide; lernapps update replaces the lernapps-* skills.
1463
1689
 
1464
- Then: git init (if <dir> is not a repository yet), npm install (installs the git hooks), and the first commit.
1690
+ It installs nothing and commits nothing. Its last lines say what to do next, in this order: git init
1691
+ --initial-branch=work (only if <dir> is not a repository yet; lernapps init made one), npm install (installs the
1692
+ runtime and the git hooks), git add -A and git commit (the first commit, on work; the hooks check it), then follow
1693
+ the plan.
1465
1694
 
1466
1695
  Exit codes: 0 when the app is written, 1 when it cannot be (the message says why), 2 on a usage error.
1467
1696
 
@@ -1471,8 +1700,8 @@ Options:
1471
1700
  its file name <NN>-<short name>.md, else 01-plan.md (default: the one in <dir>)
1472
1701
  --templates <dir> the package @lernapps/app-templates to copy from: its folder, or a clone of its repository
1473
1702
  (default: the environment variable LERNAPPS_TEMPLATES, else the installed package)
1474
- --tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: ^ the version of this
1475
- @lernapps/tooling)
1703
+ --tooling <spec> the npm spec of @lernapps/tooling the app depends on (default: the one lernapps init
1704
+ installed, else ^ the version of this @lernapps/tooling)
1476
1705
  -h, --help print this help
1477
1706
  `;
1478
1707
  /** The generator cannot write the app; the message says why and what to do. */
@@ -1504,6 +1733,17 @@ function templatesPackage(dir) {
1504
1733
  scaffold: manifest.lernapps?.scaffold ?? {}
1505
1734
  };
1506
1735
  }
1736
+ /** The version of a package built from a commit, not released: its package.json in the repository says 0.0.0. */
1737
+ const UNRELEASED = "0.0.0";
1738
+ /**
1739
+ * The spec of the runtime an app depends on, for templates of `version`: ^ that version; for templates built from a
1740
+ * commit (version 0.0.0, which the registry does not have) the spec this tooling depends on, the same commit
1741
+ * (github:lernapps/app-templates#<commit>), so that template and runtime match.
1742
+ */
1743
+ function runtimeSpec(version) {
1744
+ if (version !== UNRELEASED) return `^${version}`;
1745
+ return readJson(fromPackage("package.json")).dependencies?.["@lernapps/app-templates"] ?? `^${version}`;
1746
+ }
1507
1747
  /** The version of this package. */
1508
1748
  const ownVersion = () => readJson(fromPackage("package.json")).version ?? "0.0.0";
1509
1749
  /** The lock file of `npx skills add`, next to the skills it installed. */
@@ -1536,7 +1776,7 @@ function contents(dir) {
1536
1776
  return [name];
1537
1777
  });
1538
1778
  }
1539
- function git(args, cwd) {
1779
+ function git$1(args, cwd) {
1540
1780
  const result = spawnSync("git", args, {
1541
1781
  cwd,
1542
1782
  encoding: "utf8"
@@ -1570,6 +1810,8 @@ function writeScaffold(templates, archetype, dir) {
1570
1810
  if (!existsSync(start)) throw new CreateError(`${templates.root} has no start content scaffold/${archetype}/`);
1571
1811
  cpSync(start, dir, { recursive: true });
1572
1812
  }
1813
+ /** The branch the plan works on (rule 8 of the plan template). */
1814
+ const WORK_BRANCH = "work";
1573
1815
  /** This package, as an app depends on it. */
1574
1816
  const TOOLING = "@lernapps/tooling";
1575
1817
  /**
@@ -1735,7 +1977,7 @@ function create(args) {
1735
1977
  };
1736
1978
  if (app.devDependencies?.["@lernapps/app-templates"] !== void 0) app.devDependencies = {
1737
1979
  ...app.devDependencies,
1738
- [RUNTIME]: `^${templates.version}`
1980
+ [RUNTIME]: runtimeSpec(templates.version)
1739
1981
  };
1740
1982
  const overrides = app["overrides"];
1741
1983
  if (typeof overrides === "object" && overrides !== null && "@lernapps/app-templates" in overrides) {
@@ -1743,7 +1985,7 @@ function create(args) {
1743
1985
  if (Object.keys(kept).length > 0) app["overrides"] = kept;
1744
1986
  else delete app["overrides"];
1745
1987
  }
1746
- const tooling = values.tooling ?? `^${ownVersion()}`;
1988
+ const tooling = values.tooling ?? initialised?.devDependencies?.["@lernapps/tooling"] ?? `^${ownVersion()}`;
1747
1989
  app.devDependencies = {
1748
1990
  ...app.devDependencies,
1749
1991
  ...ARCHETYPE_DEV_DEPENDENCIES[archetype],
@@ -1752,7 +1994,7 @@ function create(args) {
1752
1994
  };
1753
1995
  app.devDependencies = Object.fromEntries(Object.entries(app.devDependencies).sort(([a], [b]) => a.localeCompare(b)));
1754
1996
  writeJson(join(dir, "package.json"), app);
1755
- const holder = git(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
1997
+ const holder = git$1(["config", "user.name"], existsSync(dir) ? dir : process.cwd());
1756
1998
  const moved = resolve(plan.file) === resolve(join(dir, LEGACY_PLAN));
1757
1999
  writePlan(dir, plan);
1758
2000
  writeAppFiles(dir, {
@@ -1765,7 +2007,13 @@ function create(args) {
1765
2007
  `lernapps create: the ${archetype} app ${name} in ${dir} (app-templates ${templates.version})`,
1766
2008
  ...guide.map((line) => `Content guide: ${line}`),
1767
2009
  ...moved ? [`Plan: moved ${LEGACY_PLAN} to ${relative(dir, plan.target)}`] : [],
1768
- `Next: ${existsSync(join(dir, ".git")) ? "" : "git init, "}npm install, then follow ${relative(dir, plan.target)}`,
2010
+ "Next, in this order (create installs and commits nothing):",
2011
+ ...[
2012
+ ...existsSync(join(dir, ".git")) ? [] : [`git init --initial-branch=${WORK_BRANCH}`],
2013
+ "npm install (the runtime and the git hooks)",
2014
+ "git add -A, then git commit (the first commit; the hooks check it)",
2015
+ `follow ${relative(dir, plan.target)}`
2016
+ ].map((step, index) => ` ${index + 1}. ${step}`),
1769
2017
  ""
1770
2018
  ].join("\n"));
1771
2019
  return 0;
@@ -1774,17 +2022,19 @@ function create(args) {
1774
2022
  /** The options of `lernapps init`, as node:util parseArgs takes them. */
1775
2023
  const INIT_OPTIONS = {
1776
2024
  addon: { type: "string" },
2025
+ tooling: { type: "string" },
1777
2026
  help: {
1778
2027
  type: "boolean",
1779
2028
  short: "h",
1780
2029
  default: false
1781
2030
  }
1782
2031
  };
1783
- const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>]
2032
+ const INIT_USAGE = `Usage: lernapps init <short name> [--addon <package>] [--tooling <spec>]
1784
2033
 
1785
2034
  Starts an app in the current folder, which must be empty except for .git. It needs git, Node and the npm registry; nothing
1786
2035
  else. In order:
1787
- git repository on the branch work, which the plan names (unless the folder is one already; no commit)
2036
+ git repository on the branch work, which the plan names; no commit. A repository without commits
2037
+ switches to work; one with commits stays, and init warns: git switch -c work
1788
2038
  package.json names the folder, depends on @lernapps/tooling (this version) and the add-on
1789
2039
  npm install installs them
1790
2040
  .vibe/plans/01-<short name>.md
@@ -1796,6 +2046,12 @@ Then follow the plan: it says what to do, and when to wait for the creator.
1796
2046
 
1797
2047
  Options:
1798
2048
  --addon <package> a package that extends the tooling, e.g. @lernapps/practice: its plan template and its skills
2049
+ --tooling <spec> for maintainers: the npm spec of @lernapps/tooling to install instead of this version, e.g.
2050
+ file:/path/to/lernapps-tooling-0.0.0.tgz or github:lernapps/tooling#<commit>; give the same
2051
+ to lernapps create. Without it, a tooling of version 0.0.0 (a local build, not on the
2052
+ registry) is packed with npm pack into a temporary folder and installed from that tarball
2053
+ when its folder is a git checkout (file:<tarball>), with its dependencies, as from the
2054
+ registry
1799
2055
  -h, --help print this help
1800
2056
  `;
1801
2057
  /** The options of `lernapps skills`. */
@@ -1822,25 +2078,57 @@ const SKILLS_BIN = () => {
1822
2078
  const bin = readJson(manifest)["bin"];
1823
2079
  return join(dirname(manifest), bin?.["skills"] ?? "bin/cli.mjs");
1824
2080
  };
1825
- /** The branch the plan works on (rule 8 of the plan template). */
1826
- const WORK_BRANCH = "work";
1827
- /**
1828
- * Makes the folder a git repository on the branch `work`, which the plan names, unless it is one already: `create` and
1829
- * the hooks need it, and the plan's first rule is to work there. Returns what it did, for the output; no commit.
1830
- */
1831
- function ensureRepository(dir) {
1832
- if (existsSync(join(dir, ".git"))) return void 0;
1833
- const result = spawnSync("git", [
1834
- "init",
1835
- "--quiet",
1836
- `--initial-branch=${WORK_BRANCH}`
1837
- ], {
2081
+ /** A git command in `dir`; a git that cannot run is an error of init. */
2082
+ function git(dir, args) {
2083
+ const result = spawnSync("git", args, {
1838
2084
  cwd: dir,
1839
2085
  encoding: "utf8"
1840
2086
  });
1841
2087
  if (result.error) throw new CreateError(`git cannot run: ${result.error.message}. Install git, then run init again`);
1842
- if (result.status !== 0) throw new CreateError(`git init failed: ${`${result.stdout}${result.stderr}`.trim()}`);
1843
- return `a git repository on the branch ${WORK_BRANCH}`;
2088
+ return {
2089
+ ok: result.status === 0,
2090
+ out: `${result.stdout}${result.stderr}`.trim()
2091
+ };
2092
+ }
2093
+ /**
2094
+ * Puts the folder on the branch `work`, which the plan names: `create` and the hooks need a repository, and the plan's
2095
+ * first rule is to work there. No repository yet → `git init` on `work`. A repository without commits (a fresh
2096
+ * `git init` on master or main, a clone of an empty GitHub repo) → its unborn branch becomes `work`: nothing is lost,
2097
+ * since there is nothing on it. A repository with commits on another branch is left as it is, with a warning that
2098
+ * names the command. No commit.
2099
+ */
2100
+ function ensureRepository(dir) {
2101
+ if (!existsSync(join(dir, ".git"))) {
2102
+ const result = git(dir, [
2103
+ "init",
2104
+ "--quiet",
2105
+ `--initial-branch=${WORK_BRANCH}`
2106
+ ]);
2107
+ if (!result.ok) throw new CreateError(`git init failed: ${result.out}`);
2108
+ return { done: `a git repository on the branch ${WORK_BRANCH}` };
2109
+ }
2110
+ const branch = git(dir, [
2111
+ "symbolic-ref",
2112
+ "--quiet",
2113
+ "--short",
2114
+ "HEAD"
2115
+ ]);
2116
+ if (branch.ok && branch.out === "work") return {};
2117
+ if (!git(dir, [
2118
+ "rev-parse",
2119
+ "--verify",
2120
+ "--quiet",
2121
+ "HEAD"
2122
+ ]).ok) {
2123
+ const result = git(dir, [
2124
+ "symbolic-ref",
2125
+ "HEAD",
2126
+ `refs/heads/${WORK_BRANCH}`
2127
+ ]);
2128
+ if (!result.ok) throw new CreateError(`git could not switch to the branch ${WORK_BRANCH}: ${result.out}`);
2129
+ return { done: `the repository had no commit yet: now on the branch ${WORK_BRANCH}` };
2130
+ }
2131
+ return { warning: `the repository has commits and is ${branch.ok ? `on the branch ${branch.out}` : "on no branch (detached HEAD)"}, not on ${WORK_BRANCH}, which the plan works on. Run next: git switch -c ${WORK_BRANCH}` };
1844
2132
  }
1845
2133
  /** The names of the skills of the archetypes, with the archetype each belongs to (lernapps-<archetype>[-…]). */
1846
2134
  function archetypeSkills() {
@@ -1879,6 +2167,37 @@ function syncSkills(dir, exclude) {
1879
2167
  ...filters.length > 0 ? ["--exclude", ...filters] : []
1880
2168
  ], (message) => new CreateError(message), dir);
1881
2169
  }
2170
+ /** The version of a build that is not on the registry: package.json of the repository says 0.0.0. */
2171
+ const LOCAL_VERSION = "0.0.0";
2172
+ /**
2173
+ * The tarball of the package in `root`, packed by npm pack (with its prepare script, so the build is current) into a
2174
+ * new temporary folder.
2175
+ */
2176
+ function packBuild(root) {
2177
+ const out = mkdtempSync(join(tmpdir(), "lernapps-tooling-"));
2178
+ run("npm", [
2179
+ "pack",
2180
+ "--silent",
2181
+ "--pack-destination",
2182
+ out
2183
+ ], (message) => new CreateError(message), root);
2184
+ const tarball = readdirSync(out).find((name) => name.endsWith(".tgz"));
2185
+ if (tarball === void 0) throw new CreateError(`npm pack in ${root} wrote no tarball into ${out}`);
2186
+ return join(out, tarball);
2187
+ }
2188
+ /**
2189
+ * The spec of @lernapps/tooling that init installs: the one given, else this version. A local build (version 0.0.0,
2190
+ * which the registry does not have) is packed when its folder is a git checkout and installed from the tarball, else
2191
+ * init says to name one with --tooling. Not the folder itself (file:<folder>): npm links it, so the app's
2192
+ * node_modules lacks the tooling's dependencies (Playwright for the end-to-end tests, @lernapps/app-templates with
2193
+ * the templates and their READMEs); from a tarball they are installed into the app, as from the registry.
2194
+ */
2195
+ function toolingSpec(given, version = ownVersion(), root = fromPackage(), pack = packBuild) {
2196
+ if (given !== void 0 && given !== "") return given;
2197
+ if (version !== LOCAL_VERSION) return version;
2198
+ if (existsSync(join(root, ".git"))) return `file:${pack(resolve(root))}`;
2199
+ throw new CreateError(`this @lernapps/tooling is a local build (version ${LOCAL_VERSION}), which the npm registry does not have: name the tooling to install with --tooling <spec>, e.g. file:/path/to/lernapps-tooling-${LOCAL_VERSION}.tgz (npm pack) or github:lernapps/tooling#<commit>, and give the same to lernapps create`);
2200
+ }
1882
2201
  function init(args) {
1883
2202
  const { values, positionals } = parseArgs({
1884
2203
  args,
@@ -1896,6 +2215,7 @@ function init(args) {
1896
2215
  const present = readdirSync(dir).filter((entry) => entry !== ".git");
1897
2216
  if (present.length > 0) throw new CreateError(`${dir} is not empty (${present.slice(0, 5).join(", ")}${present.length > 5 ? ", ..." : ""}): start in a new, empty folder`);
1898
2217
  const addon = values.addon;
2218
+ const tooling = toolingSpec(values.tooling);
1899
2219
  const repository = ensureRepository(dir);
1900
2220
  writeJson(join(dir, "package.json"), {
1901
2221
  name,
@@ -1909,7 +2229,7 @@ function init(args) {
1909
2229
  "--save-dev",
1910
2230
  "--no-audit",
1911
2231
  "--no-fund",
1912
- `${TOOLING}@${ownVersion()}`,
2232
+ `${TOOLING}@${tooling}`,
1913
2233
  ...addon ? [addon] : []
1914
2234
  ], fail);
1915
2235
  const source = (packageName, ...path) => join(dir, "node_modules", packageName, ...path);
@@ -1922,7 +2242,8 @@ function init(args) {
1922
2242
  mkdirSync(dirname(target), { recursive: true });
1923
2243
  writeFileSync(target, readFileSync(template));
1924
2244
  syncSkills(dir, archetypeSkills().map((skill) => skill.name));
1925
- process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed${repository ? `; ${repository}` : ""}\nNext: read ${relative$1(dir, target)} completely and follow it\n`);
2245
+ if (repository.warning !== void 0) process.stderr.write(`lernapps init: warning: ${repository.warning}\n`);
2246
+ process.stdout.write(`lernapps init: ${relative$1(dir, target)} from ${relativeTemplate(template, dir)}; the skills of the packages are installed${repository.done ? `; ${repository.done}` : ""}\nNext: ${repository.warning ? `git switch -c ${WORK_BRANCH}, then ` : ""}read ${relative$1(dir, target)} completely and follow it\n`);
1926
2247
  return 0;
1927
2248
  }
1928
2249
  const relative$1 = (from, to) => to.startsWith(from) ? to.slice(from.length + 1) : to;
@@ -1960,13 +2281,16 @@ const UPDATE_USAGE = `Usage: lernapps update
1960
2281
  Moves the app in the current folder to the versions of this @lernapps/tooling, then runs npm install. npm update
1961
2282
  moves the app within its version ranges; this moves the ranges, and what npm does not know:
1962
2283
  @lernapps/app-templates to the version this tooling depends on: the runtime its tests ran with. A newer version
1963
- (e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version
2284
+ (e.g. from Renovate) is kept. A git commit, as apps once pinned, becomes a version; while
2285
+ this tooling depends on a commit (an unreleased runtime), the app moves to that commit
1964
2286
  @lernapps/tooling to the version of this tooling (unchanged when run from the app)
1965
2287
  .github/workflows/ the actions of lernapps/tooling at the tag of that version
1966
2288
  .agents/skills/app-content/SKILL.md
1967
2289
  the content guide's skeleton, where the app has none yet (and its link for Claude Code)
1968
2290
  .vibe/plan.md the plan of an app made before the plans had a folder: moved to
1969
2291
  .vibe/plans/01-first-version.md, the first of its plans (one per piece of work)
2292
+ src/app.yaml of an explainer in format 1, when the runtime knows format 2: adds format: 2 and
2293
+ strand: none (the app is not on the learning map; its pages need no competencies)
1970
2294
  .agents/skills/lernapps-*, .claude/skills/lernapps-*, skills-lock.json
1971
2295
  replaced with the skills of this tooling, as npx skills add lays them out: those of
1972
2296
  every phase, and of the archetypes only those of the current plan's archetype; every
@@ -2000,9 +2324,11 @@ function lowest(range) {
2000
2324
  }
2001
2325
  /** Whether `a` is older than `b`. */
2002
2326
  const older = (a, b) => a[0] !== b[0] ? a[0] < b[0] : a[1] !== b[1] ? a[1] < b[1] : a[2] < b[2];
2003
- /** The next range of a dependency at `version`, and what the output says about it. */
2004
- function nextRange(name, current, version) {
2005
- const target = `^${version}`;
2327
+ /**
2328
+ * The next range of a dependency at `version` (target: ^ that version, or the spec given, e.g. a git commit of an
2329
+ * unreleased runtime), and what the output says about it.
2330
+ */
2331
+ function nextRange(name, current, version, target = `^${version}`) {
2006
2332
  if (current !== void 0 && /^(file:|link:|\.{0,2}\/)/.test(current)) return {
2007
2333
  spec: current,
2008
2334
  line: `${name}: kept ${current}, a local copy`
@@ -2013,7 +2339,7 @@ function nextRange(name, current, version) {
2013
2339
  spec: current,
2014
2340
  line: `${name}: unchanged (${current})`
2015
2341
  };
2016
- if (now !== void 0 && next !== void 0 && !older(now, next)) return {
2342
+ if (lowest(target) !== void 0 && now !== void 0 && next !== void 0 && !older(now, next)) return {
2017
2343
  spec: current ?? target,
2018
2344
  line: `${name}: kept ${current}, not moved to ${target}: it is not older`
2019
2345
  };
@@ -2047,6 +2373,30 @@ function migratePlan() {
2047
2373
  renameSync(LEGACY_PLAN, MIGRATED_PLAN);
2048
2374
  return [`plan: moved ${LEGACY_PLAN} to ${MIGRATED_PLAN}, unchanged: each piece of work has its own plan in .vibe/plans/`];
2049
2375
  }
2376
+ /** The explainer's app file, and the schema file that tells the runtime knows format 2 (learning map). */
2377
+ const EXPLAINER_APP = join("src", "app.yaml");
2378
+ const EXPLAINER_V2_SCHEMA = join("runtime", "explainer", "explainer.v2.schema.json");
2379
+ /**
2380
+ * Moves an explainer from format 1 to format 2 (the learning map): adds `format: 2` and `strand: none` to
2381
+ * src/app.yaml when the app has neither, and points its schema comment to explainer.v2.schema.json. `strand: none`
2382
+ * keeps the app off the map, so its pages need no competencies; the creator may name a strand later (skill
2383
+ * lernapps-content-research). Only where the runtime of this tooling has the v2 schema: an older runtime does not
2384
+ * know the fields. An app that is no explainer, or already has a `format` or a `strand`, stays as it is. What it did,
2385
+ * as lines of the output.
2386
+ */
2387
+ function migrateExplainer(templatesRoot) {
2388
+ if (!existsSync(EXPLAINER_APP) || !existsSync(join(templatesRoot, EXPLAINER_V2_SCHEMA))) return [];
2389
+ const text = readFileSync(EXPLAINER_APP, "utf8");
2390
+ const top = (key) => new RegExp(`^${key}:`, "m").test(text);
2391
+ if (!top("chapters") || top("format") || top("strand")) return [];
2392
+ const schema = /^(# yaml-language-server: \$schema=.*)explainer\.v1\.schema\.json$/m;
2393
+ const fields = "format: 2\nstrand: none\n";
2394
+ let next;
2395
+ if (schema.test(text)) next = text.replace(schema, (_all, head) => `${head}explainer.v2.schema.json\n${fields.trimEnd()}`);
2396
+ else next = fields + text;
2397
+ writeFileSync(EXPLAINER_APP, next);
2398
+ return [`explainer: ${EXPLAINER_APP} moved to format 2: added format: 2 and strand: none (not on the learning map; pages need no competencies). To join the map, pick a strand of https://lernapps.net/apps/map/ and give every page competencies`];
2399
+ }
2050
2400
  /**
2051
2401
  * The skills of this tooling, as it ships them (skills/<group>/<name>/), and as the app keeps them (flat, by name).
2052
2402
  * Every app has those of the groups process, tooling and content; of the group archetypes only those of its own
@@ -2255,7 +2605,7 @@ function update(args) {
2255
2605
  const notMoved = [];
2256
2606
  const runtime = devDependencies[RUNTIME];
2257
2607
  if (runtime !== void 0) {
2258
- const next = nextRange(RUNTIME, runtime, templates.version);
2608
+ const next = nextRange(RUNTIME, runtime, templates.version, runtimeSpec(templates.version));
2259
2609
  if (next.spec !== runtime) specs.push(`${RUNTIME}@${next.spec}`);
2260
2610
  lines.push(next.line);
2261
2611
  }
@@ -2285,6 +2635,8 @@ function update(args) {
2285
2635
  lines.push(...guide.map((line) => `content guide: ${line}`));
2286
2636
  const plan = migratePlan();
2287
2637
  lines.push(...plan);
2638
+ const explainer = migrateExplainer(templates.root);
2639
+ lines.push(...explainer);
2288
2640
  const skills = [...replaceSkills(), ...unignoreSkills()];
2289
2641
  lines.push(...skills);
2290
2642
  const architecture = updateArchitecture();
@@ -2302,6 +2654,7 @@ function update(args) {
2302
2654
  if (skillsMoved) files.push(SKILLS_LOCK);
2303
2655
  if (skills.some((line) => line.includes(".gitignore"))) files.push(".gitignore");
2304
2656
  if (architecture.lines.length > 0) files.push(`${ARCHITECTURE}/`);
2657
+ if (explainer.length > 0) files.push(EXPLAINER_APP);
2305
2658
  if (plan.some((line) => line.startsWith("plan: moved"))) files.push(".vibe/ (git add -A .vibe, to keep the move)");
2306
2659
  process.stdout.write(`Next: npx --no -- lernapps check, then commit ${files.slice(0, -1).join(", ")} and ${files.at(-1) ?? ""}\n`);
2307
2660
  return 0;
@@ -2437,6 +2790,13 @@ async function shootFigures(browser, base, page, width, file) {
2437
2790
  return count;
2438
2791
  });
2439
2792
  }
2793
+ /** Where an app's build is: dist/ (npx vp build, as lernapps check builds it) or _site/ (npm run build of the templates). */
2794
+ const BUILDS = ["dist", "_site"];
2795
+ const isBuild = (dir) => existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "index.html"));
2796
+ /** The build to take without <dir>: of dist/ and _site/ the one with an index.html, the newer of both when both have one. */
2797
+ function defaultBundle() {
2798
+ return BUILDS.filter((dir) => isBuild(dir)).sort((a, b) => statSync(join(b, "index.html")).mtimeMs - statSync(join(a, "index.html")).mtimeMs)[0] ?? "dist";
2799
+ }
2440
2800
  /**
2441
2801
  * Writes the images and the index.html. Everything that can fail before is checked before the folder is touched, so a
2442
2802
  * failed run writes nothing. Returns the line to print.
@@ -2451,8 +2811,9 @@ async function screenshots(args) {
2451
2811
  if (positionals.length > 1) throw new UsageError(`one <dir> at most, not ${positionals.join(" ")}`);
2452
2812
  if (!/^\d+$/.test(values.width) || Number(values.width) < 200 || Number(values.width) > 4e3) throw new UsageError(`--width ${values.width}: a number of pixels from 200 to 4000`);
2453
2813
  const widths = [.../* @__PURE__ */ new Set([Number(values.width), ...values.desktop ? [DESKTOP] : []])];
2454
- const bundle = resolve(positionals[0] ?? "dist");
2455
- if (!existsSync(bundle) || !statSync(bundle).isDirectory() || !existsSync(join(bundle, "index.html"))) throw new UsageError(`${positionals[0] ?? "dist"}: not a directory with index.html (build the app first)`);
2814
+ const named = positionals[0] ?? defaultBundle();
2815
+ const bundle = resolve(named);
2816
+ if (!isBuild(bundle)) throw new UsageError(positionals[0] === void 0 ? `neither ${BUILDS.map((dir) => `${dir}/`).join(" nor ")} has an index.html (build the app first: npx vp build writes dist/, npm run build _site/)` : `${named}: not a directory with index.html (build the app first)`);
2456
2817
  const archetype = archetypeOf(readFileSync(join(bundle, "index.html"), "utf8"));
2457
2818
  if (archetype === void 0 || !SUPPORTED.includes(archetype)) throw new ScreenshotsError(`screenshots support explainer and quiz, not ${archetype === void 0 ? "this app" : `the ${archetype} archetype`}`);
2458
2819
  const out = resolve(values.out);
@@ -2514,6 +2875,34 @@ const REPORT = "node_modules/.cache/lernapps/validation-report.yaml";
2514
2875
  const CHECKS_PASS = "checks-pass";
2515
2876
  /** A built app or a URL that could not be checked at all: a platform rule, unlike the toolchain's checks-pass. */
2516
2877
  const APP_CHECKABLE = "app-checkable";
2878
+ /** The duration of every step of the last check in a repo that ran: not in the report, which is deterministic. */
2879
+ const TIMINGS = "node_modules/.cache/lernapps/timings.json";
2880
+ /** The steps of this run and how long each took, in seconds, for TIMINGS. */
2881
+ const timings = [];
2882
+ /** Runs `run` and records how long it took as the step `name`. */
2883
+ function timed(name, run) {
2884
+ const start = performance.now();
2885
+ try {
2886
+ return run();
2887
+ } finally {
2888
+ timings.push({
2889
+ step: name,
2890
+ seconds: Math.round(performance.now() - start) / 1e3
2891
+ });
2892
+ }
2893
+ }
2894
+ /** Like timed, for a step that returns a promise. */
2895
+ async function timedAsync(name, run) {
2896
+ const start = performance.now();
2897
+ try {
2898
+ return await run();
2899
+ } finally {
2900
+ timings.push({
2901
+ step: name,
2902
+ seconds: Math.round(performance.now() - start) / 1e3
2903
+ });
2904
+ }
2905
+ }
2517
2906
  /** The Playwright tests' results, as its JSON reporter writes them, to tell the runtime's tests from the app's. */
2518
2907
  const E2E_RESULTS = "node_modules/.cache/lernapps/playwright-results.json";
2519
2908
  const RUNTIME_ISSUES = "https://github.com/lernapps/app-templates/issues";
@@ -2852,6 +3241,9 @@ function architectureDiffFindings(where, stdout, stderr) {
2852
3241
  }
2853
3242
  /** Runs a step and returns its findings: a diff's as architectureDiffFindings judges them, else runStep's one. */
2854
3243
  function stepFindings(step) {
3244
+ return timed(step.name, () => untimedStepFindings(step));
3245
+ }
3246
+ function untimedStepFindings(step) {
2855
3247
  if (!step.architectureDiff) {
2856
3248
  const failure = runStep(step);
2857
3249
  return failure ? [failure] : [];
@@ -2937,12 +3329,12 @@ async function checkProject(fast, heavy, context) {
2937
3329
  if (heavy && built) {
2938
3330
  context.project = process.cwd();
2939
3331
  if (existsSync(join("dist", "index.html"))) try {
2940
- context.app = await visit({ bundle: "dist" });
3332
+ context.app = await timedAsync("visit of the built app", () => visit({ bundle: "dist" }));
2941
3333
  } catch (error) {
2942
3334
  if (!(error instanceof BrowserError)) throw error;
2943
3335
  findings.push(browserFinding(error));
2944
3336
  }
2945
- const checks = await runChecks(context);
3337
+ const checks = await timedAsync("checks of the built app", () => runChecks(context));
2946
3338
  rules.push(...checks.rules);
2947
3339
  findings.push(...checks.findings);
2948
3340
  if ([
@@ -2951,7 +3343,8 @@ async function checkProject(fast, heavy, context) {
2951
3343
  "mjs",
2952
3344
  "cjs"
2953
3345
  ].map((ext) => `playwright.config.${ext}`).find((file) => existsSync(file)) !== void 0) try {
2954
- const failure = runEndToEndTests(await browserForTests());
3346
+ const browser = await browserForTests();
3347
+ const failure = timed("end-to-end tests", () => runEndToEndTests(browser));
2955
3348
  if (failure) findings.push(failure);
2956
3349
  } catch (error) {
2957
3350
  if (!(error instanceof BrowserError)) throw error;
@@ -3014,6 +3407,62 @@ function checkSite(script) {
3014
3407
  suppressions: []
3015
3408
  };
3016
3409
  }
3410
+ /**
3411
+ * The check of a repo: first whether node_modules holds what package-lock.json asks for (else nothing else runs, its
3412
+ * result would not be CI's). The heavy part warns on standard error when Node has another major version than CI,
3413
+ * then reuses a passing report of the same state (check/cache.ts) or waits for another check in this clone.
3414
+ */
3415
+ async function checkRepo(fast, heavy, context, noCache) {
3416
+ const parts = [...fast ? ["pre-commit"] : [], ...heavy ? ["pre-push"] : []];
3417
+ const installed = timed("installed packages", () => installedProblems("."));
3418
+ if (installed.length > 0) return { report: {
3419
+ checked: commitOrDirectory(),
3420
+ parts,
3421
+ rules: [CHECKS_PASS],
3422
+ findings: installed.map((problem) => finding(CHECKS_PASS, "error", problem)),
3423
+ suppressions: []
3424
+ } };
3425
+ if (!heavy) return { report: await checkProject(fast, heavy, context) };
3426
+ const node = nodeMajorProblem();
3427
+ if (node !== void 0) process.stderr.write(`lernapps check: warning: ${node.found}. ${node.fix}\n`);
3428
+ const dir = stateDir();
3429
+ const keyOf = () => {
3430
+ if (noCache || dir === void 0) return void 0;
3431
+ const key = cacheKey(".", parts, version());
3432
+ return "key" in key ? key.key : void 0;
3433
+ };
3434
+ const cached = (key) => {
3435
+ if (key === void 0 || dir === void 0) return void 0;
3436
+ const hit = cachedReport(dir, key);
3437
+ if (hit === void 0) return void 0;
3438
+ return {
3439
+ report: {
3440
+ ...parse(hit.report),
3441
+ checked: commitOrDirectory()
3442
+ },
3443
+ cachedAt: hit.at
3444
+ };
3445
+ };
3446
+ const before = keyOf();
3447
+ const hit = cached(before);
3448
+ if (hit !== void 0) return hit;
3449
+ let waited = false;
3450
+ const release = dir === void 0 ? () => {} : await acquireLock(dir, (owner) => {
3451
+ waited = true;
3452
+ process.stderr.write(`lernapps check: waiting for another lernapps check in this clone${owner === void 0 ? "" : ` (process ${owner.pid}, since ${owner.at})`}\n`);
3453
+ });
3454
+ try {
3455
+ if (waited) {
3456
+ const after = cached(keyOf());
3457
+ if (after !== void 0) return after;
3458
+ }
3459
+ const report = await checkProject(fast, heavy, context);
3460
+ if (!failed(report) && before !== void 0 && dir !== void 0 && keyOf() === before) storeReport(dir, before, toYaml(report));
3461
+ return { report };
3462
+ } finally {
3463
+ release();
3464
+ }
3465
+ }
3017
3466
  /** The checks of a built app alone: a bundle directory or a URL. */
3018
3467
  async function checkApp(target, context) {
3019
3468
  const isUrl = /^https?:\/\//.test(target);
@@ -3054,6 +3503,9 @@ async function check(args) {
3054
3503
  if (values.entry !== void 0) context.entry = readEntry(values.entry);
3055
3504
  const target = positionals[0] ?? context.entry?.value.url;
3056
3505
  let report;
3506
+ /** When the report comes from the cache: when that state was checked. */
3507
+ let cachedAt;
3508
+ const start = performance.now();
3057
3509
  if (target !== void 0) {
3058
3510
  if (values["pre-commit"] || values["pre-push"]) throw new UsageError("--pre-commit and --pre-push check a repo; a built app is checked without them");
3059
3511
  report = await checkApp(target, context);
@@ -3073,21 +3525,32 @@ async function check(args) {
3073
3525
  process.stdout.write(`${scope.skip}\n`);
3074
3526
  return 0;
3075
3527
  } else {
3076
- report = scope !== void 0 ? checkSite(scope.site) : await checkProject(all || values["pre-commit"], all || values["pre-push"], context);
3528
+ if (scope !== void 0) report = checkSite(scope.site);
3529
+ else {
3530
+ const result = await checkRepo(all || values["pre-commit"], all || values["pre-push"], context, values["no-cache"]);
3531
+ report = result.report;
3532
+ cachedAt = result.cachedAt;
3533
+ }
3077
3534
  if (values["pre-push"] && failed(report)) countPrePushFailure();
3078
3535
  }
3079
3536
  }
3080
3537
  if (target === void 0 && failed(report)) countFailedChecks(report.findings.filter((found) => found.severity === "error").map((found) => found.rule));
3081
3538
  const yaml = toYaml(report);
3539
+ const seconds = Math.round((performance.now() - start) / 100) / 10;
3082
3540
  if (target === void 0) {
3083
3541
  mkdirSync(dirname(REPORT), { recursive: true });
3084
3542
  writeFileSync(REPORT, yaml);
3543
+ if (cachedAt === void 0 && timings.length > 0) writeFileSync(TIMINGS, `${JSON.stringify({
3544
+ seconds,
3545
+ steps: timings
3546
+ }, null, 2)}\n`);
3085
3547
  }
3086
3548
  if (values.report !== void 0) writeFileSync(values.report, yaml);
3087
3549
  if (report.findings.length > 0) process.stdout.write(yaml);
3088
3550
  else {
3089
3551
  const what = "commit" in report.checked ? `commit ${report.checked.commit.slice(0, 7)}` : "bundle" in report.checked ? relative(process.cwd(), resolve(report.checked.bundle)) || "." : "url" in report.checked ? report.checked.url : report.checked.directory;
3090
- process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})\n`);
3552
+ const how = cachedAt !== void 0 ? `, already checked at ${cachedAt.toTimeString().slice(0, 5)} (--no-cache checks again)` : ` in ${seconds} s`;
3553
+ process.stdout.write(`lernapps check: ok, ${report.rules.length} rule${report.rules.length === 1 ? "" : "s"}, no findings (${what})${how}\n`);
3091
3554
  }
3092
3555
  return failed(report) ? 1 : 0;
3093
3556
  }