@plebeiantech/lat.md 0.12.3-fork.4 → 0.12.3-fork.6

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 (64) hide show
  1. package/dist/src/cli/check-coverage.d.ts +2 -0
  2. package/dist/src/cli/check-coverage.js +71 -0
  3. package/dist/src/cli/check-mode.d.ts +6 -0
  4. package/dist/src/cli/check-mode.js +44 -2
  5. package/dist/src/cli/check.js +5 -1
  6. package/dist/src/cli/fork-instructions.d.ts +19 -0
  7. package/dist/src/cli/fork-instructions.js +183 -0
  8. package/dist/src/cli/fork-scaffold.d.ts +16 -0
  9. package/dist/src/cli/fork-scaffold.js +129 -0
  10. package/dist/src/cli/index.js +29 -4
  11. package/dist/src/cli/init.js +4 -0
  12. package/dist/src/cli/search.js +6 -4
  13. package/dist/src/cli/ui-build.d.ts +4 -0
  14. package/dist/src/cli/ui-build.js +13 -0
  15. package/dist/src/cli/ui.js +18 -2
  16. package/dist/src/code-refs.js +25 -4
  17. package/dist/src/fork/upstream-drift-cli.d.ts +1 -0
  18. package/dist/src/fork/upstream-drift-cli.js +2 -0
  19. package/dist/src/fork/upstream-drift.d.ts +26 -0
  20. package/dist/src/fork/upstream-drift.js +185 -0
  21. package/dist/src/fork/upstream-guard-cli.d.ts +1 -0
  22. package/dist/src/fork/upstream-guard-cli.js +2 -0
  23. package/dist/src/fork/upstream-guard.d.ts +72 -0
  24. package/dist/src/fork/upstream-guard.js +445 -0
  25. package/dist/src/init-version.d.ts +1 -1
  26. package/dist/src/init-version.js +1 -1
  27. package/dist/src/lattice.d.ts +2 -1
  28. package/dist/src/lattice.js +1 -0
  29. package/dist/src/search/search.d.ts +1 -0
  30. package/dist/src/search/search.js +15 -9
  31. package/dist/src/view/client/assets/index-VZwPPiFE.js +334 -0
  32. package/dist/src/view/client/assets/index-W8ktvNj9.css +1 -0
  33. package/dist/src/view/client/assets/logo-CNF8zs47.svg +182 -0
  34. package/dist/src/view/client/index.html +2 -2
  35. package/dist/src/view/git-diff.js +17 -0
  36. package/dist/src/view/graph.d.ts +6 -0
  37. package/dist/src/view/graph.js +176 -0
  38. package/dist/src/view/markdown.d.ts +5 -1
  39. package/dist/src/view/markdown.js +112 -25
  40. package/dist/src/view/protocol.d.ts +43 -0
  41. package/dist/src/view/protocol.js +1 -1
  42. package/dist/src/view/references.d.ts +2 -0
  43. package/dist/src/view/references.js +30 -8
  44. package/dist/src/view/repository.d.ts +5 -2
  45. package/dist/src/view/repository.js +13 -12
  46. package/dist/src/view/search.js +1 -0
  47. package/dist/src/view/server.d.ts +3 -0
  48. package/dist/src/view/server.js +42 -9
  49. package/dist/src/view/source-target.d.ts +8 -0
  50. package/dist/src/view/source-target.js +22 -0
  51. package/dist/src/view/static-build.d.ts +16 -0
  52. package/dist/src/view/static-build.js +408 -0
  53. package/dist/src/view/static-protocol.d.ts +23 -0
  54. package/dist/src/view/static-protocol.js +10 -0
  55. package/dist/src/view/store.d.ts +9 -1
  56. package/dist/src/view/store.js +82 -15
  57. package/dist/src/view/table-of-contents.d.ts +9 -0
  58. package/dist/src/view/table-of-contents.js +103 -0
  59. package/package.json +3 -1
  60. package/templates/AGENTS.md +2 -13
  61. package/templates/fork/conventions.md +95 -0
  62. package/templates/skill/SKILL.md +3 -14
  63. package/dist/src/view/client/assets/index-B0n0u03y.css +0 -1
  64. package/dist/src/view/client/assets/index-xm2_px4q.js +0 -23
@@ -0,0 +1,2 @@
1
+ import type { CheckError } from './check.js';
2
+ export declare function checkCoverage(latticeDir: string, projectRoot?: string): Promise<CheckError[]>;
@@ -0,0 +1,71 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { relative } from 'node:path';
3
+ import { scanCodeRefs } from '../code-refs.js';
4
+ import { listLatticeFiles } from '../lattice.js';
5
+ import { indexNameFor } from './check-mode.js';
6
+ import { toPosix } from '../walk.js';
7
+ import { basename, dirname } from 'node:path';
8
+ /**
9
+ * Fail a `lat.md/` tree that has documents and no `@lat:` ref pointing into it
10
+ * from anywhere in the codebase.
11
+ *
12
+ * `require-code-mention` was the only thing asking for refs, and it is opt-in
13
+ * frontmatter: an agent setting up a tree can simply not write it, and every
14
+ * check still passes. One did. Its reasons were both ordinary — it read a list
15
+ * of example comment markers as an exclusive language list and concluded its
16
+ * language was unsupported, and it was optimising for a green check rather
17
+ * than a correct one, so the option that could not fail was the one it took.
18
+ *
19
+ * A graph nothing points at cannot be reached from the code, which is the only
20
+ * reason it exists. So the floor is one ref, and the message says where to put
21
+ * the first one.
22
+ *
23
+ * Deliberately a floor and not a coverage ratio. Half this repository's own
24
+ * documents have no incoming ref and should not have one — a per-document rule
25
+ * would have to be either wrong here or watered down everywhere.
26
+ */
27
+ /** Nothing to anchor: a tree of pure navigation, or a project with no code. */
28
+ function isIndex(latticeDir, file) {
29
+ const holder = basename(dirname(file));
30
+ return (basename(file) ===
31
+ indexNameFor(holder === '' ? basename(latticeDir) : holder));
32
+ }
33
+ export async function checkCoverage(latticeDir, projectRoot = dirname(latticeDir)) {
34
+ const files = await listLatticeFiles(latticeDir);
35
+ const documents = files.filter((f) => !isIndex(latticeDir, f));
36
+ if (documents.length === 0)
37
+ return [];
38
+ const scan = await scanCodeRefs(projectRoot);
39
+ if (scan.files.length === 0)
40
+ return [];
41
+ if (scan.refs.length > 0)
42
+ return [];
43
+ const rootIndexPath = `${latticeDir}/${indexNameFor(basename(latticeDir))}`;
44
+ const rootIndexRel = toPosix(relative(process.cwd(), rootIndexPath));
45
+ // Quote the ref the reader should actually paste. A generic placeholder gets
46
+ // pasted verbatim and then fails `lat check code-refs`, which turns one
47
+ // error into two.
48
+ const stem = basename(latticeDir).replace(/\.md$/, '');
49
+ let anchor = `${stem}#Your Project`;
50
+ try {
51
+ const heading = (await readFile(rootIndexPath, 'utf-8')).match(/^#\s+(.+)$/m);
52
+ if (heading)
53
+ anchor = `${stem}#${heading[1].trim()}`;
54
+ }
55
+ catch {
56
+ // No root index, or unreadable — the placeholder still names the shape.
57
+ }
58
+ return [
59
+ {
60
+ file: rootIndexRel,
61
+ line: 1,
62
+ target: toPosix(relative(projectRoot, latticeDir)),
63
+ message: `${documents.length} document(s) here and no \`@lat:\` code ref anywhere in the project — nothing in the codebase can reach this graph.\n` +
64
+ ' Start at the application entrypoint (config/application.rb, src/index.ts, main.go, manage.py) and point it at the root index:\n' +
65
+ ` # @lat: [[${anchor}]]\n` +
66
+ ' Then add one ref per section that source code implements, at that code.\n' +
67
+ ' The marker is a comment syntax, not a language allowlist: `//` for C-family, `#` for Ruby, Python, shell, Elixir and the rest. The scan is textual and reads every non-markdown file.\n' +
68
+ ' An `@lat:` line is a machine directive, like a magic comment or a linter pragma. A project convention that minimises comments does not reach it.',
69
+ },
70
+ ];
71
+ }
@@ -3,4 +3,10 @@ export declare const DIATAXIS_MODES: readonly ["tutorial", "how-to", "reference"
3
3
  export type DiataxisMode = (typeof DIATAXIS_MODES)[number];
4
4
  /** Directory below lat.md/ that holds each mode. */
5
5
  export declare const MODE_DIRS: Record<DiataxisMode, string>;
6
+ /**
7
+ * The index file a directory is required to carry, e.g. `lat.md` for the root
8
+ * and `reference.md` for `reference/`. An index is navigation rather than
9
+ * content, so the `require-mode` gate below never asks one to declare a mode.
10
+ */
11
+ export declare function indexNameFor(dirName: string): string;
6
12
  export declare function checkMode(latticeDir: string, projectRoot?: string): Promise<CheckError[]>;
@@ -1,5 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
- import { dirname, relative } from 'node:path';
2
+ import { basename, dirname, join, relative } from 'node:path';
3
3
  import { listLatticeFiles, parseFrontmatter } from '../lattice.js';
4
4
  import { parse } from '../parser.js';
5
5
  import { quoteUntrusted } from '../untrusted.js';
@@ -181,9 +181,38 @@ function findImperativeHits(tree, content) {
181
181
  }
182
182
  return hits;
183
183
  }
184
+ /**
185
+ * The index file a directory is required to carry, e.g. `lat.md` for the root
186
+ * and `reference.md` for `reference/`. An index is navigation rather than
187
+ * content, so the `require-mode` gate below never asks one to declare a mode.
188
+ */
189
+ export function indexNameFor(dirName) {
190
+ return dirName.endsWith('.md') ? dirName : dirName + '.md';
191
+ }
192
+ /**
193
+ * Whether the root index turns the mode requirement on.
194
+ *
195
+ * Opt-in, and deliberately read from the tree rather than from a flag or an
196
+ * environment variable: the rule belongs to a documentation set, not to a
197
+ * machine or a CI job, and a tree that predates the rule has to keep passing.
198
+ * `lat init` stamps the flag into the root index it scaffolds, so a project
199
+ * set up after this exists is gated from its first commit while an older one
200
+ * is untouched until someone adds the line.
201
+ */
202
+ async function requireModeEnabled(latticeDir) {
203
+ const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
204
+ try {
205
+ const content = await readFile(indexPath, 'utf-8');
206
+ return parseFrontmatter(content).raw['require-mode'] === true;
207
+ }
208
+ catch {
209
+ return false;
210
+ }
211
+ }
184
212
  export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
185
213
  const files = await listLatticeFiles(latticeDir);
186
214
  const errors = [];
215
+ const requireMode = await requireModeEnabled(latticeDir);
187
216
  for (const file of files) {
188
217
  const content = await readFile(file, 'utf-8');
189
218
  const relPath = relative(process.cwd(), file);
@@ -238,8 +267,21 @@ export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
238
267
  continue;
239
268
  }
240
269
  const mode = (declared ?? dirMode);
241
- if (mode === undefined)
270
+ if (mode === undefined) {
271
+ const segments = relFromLattice.split('/');
272
+ const fileName = segments.pop();
273
+ const holder = segments.length === 0 ? basename(latticeDir) : segments.at(-1);
274
+ const isIndex = fileName === indexNameFor(holder);
275
+ if (requireMode && !isIndex) {
276
+ errors.push({
277
+ file: relPath,
278
+ line: 1,
279
+ target,
280
+ message: 'document declares no Diátaxis mode and is not in a mode directory — move it under lat.md/tutorials/, lat.md/how-to/, lat.md/reference/ or lat.md/explanation/, or declare one under its `lat:` frontmatter.\n A document that would fail its mode is usually two documents: the lookup half belongs in reference/, the reasoning half in explanation/.\n (The root index sets require-mode: true. Remove that line to turn this off.)',
281
+ });
282
+ }
242
283
  continue;
284
+ }
243
285
  const tree = parse(content);
244
286
  if (mode === 'how-to') {
245
287
  if (!orderedListPresent(tree)) {
@@ -6,6 +6,7 @@ import { scanCodeRefs } from '../code-refs.js';
6
6
  import { checkMode } from './check-mode.js';
7
7
  import { checkStatus } from './check-status.js';
8
8
  import { checkFrontmatter } from './check-frontmatter.js';
9
+ import { checkCoverage } from './check-coverage.js';
9
10
  import { indexEntryNameFromDest } from './link-scheme.js';
10
11
  import { fencedLineMask } from './gen-index.js';
11
12
  import { SOURCE_EXTENSIONS, clearSymbolCache } from '../source-parser.js';
@@ -621,6 +622,7 @@ export async function checkAllCommand(ctx, opts = {}) {
621
622
  const modeErrors = await checkMode(ctx.latDir, ctx.projectRoot);
622
623
  const statusErrors = await checkStatus(ctx.latDir, ctx.projectRoot);
623
624
  const fmErrors = await checkFrontmatter(ctx.latDir, ctx.projectRoot);
625
+ const coverageErrors = await checkCoverage(ctx.latDir, ctx.projectRoot);
624
626
  const elapsed = Date.now() - startTime;
625
627
  const allErrors = [...md.errors, ...linkErrors, ...code.errors];
626
628
  const allFiles = { ...md.files };
@@ -658,12 +660,14 @@ export async function checkAllCommand(ctx, opts = {}) {
658
660
  lines.push(...formatCheckErrors(modeErrors, s));
659
661
  lines.push(...formatCheckErrors(statusErrors, s));
660
662
  lines.push(...formatCheckErrors(fmErrors, s));
663
+ lines.push(...formatCheckErrors(coverageErrors, s));
661
664
  const totalErrors = allErrors.length +
662
665
  indexErrors.length +
663
666
  sectionErrors.length +
664
667
  modeErrors.length +
665
668
  statusErrors.length +
666
- fmErrors.length;
669
+ fmErrors.length +
670
+ coverageErrors.length;
667
671
  if (totalErrors > 0) {
668
672
  lines.push(formatErrorCount(totalErrors, s));
669
673
  return { output: lines.join('\n'), isError: true };
@@ -0,0 +1,19 @@
1
+ export declare function readForkConventions(): string;
2
+ /**
3
+ * Replace the fork block in `content`, or append one when absent.
4
+ *
5
+ * Returns null when the block is already byte-identical, so the caller can
6
+ * stay quiet rather than announce a write that did not happen.
7
+ */
8
+ export declare function spliceForkBlock(content: string, body: string): string | null;
9
+ /** Read the body currently fenced by the fork markers, or null if absent. */
10
+ export declare function extractForkBlock(content: string): string | null;
11
+ /**
12
+ * Write the fork's conventions into every instruction file and skill directory
13
+ * the selected agents produced.
14
+ *
15
+ * `fileHashes` is upstream's record, threaded through so `writeInitMeta`
16
+ * persists these entries alongside its own; a stored hash is what lets a later
17
+ * run tell "the user edited our block" from "the template moved on".
18
+ */
19
+ export declare function writeForkInstructions(root: string, latDir: string, fileHashes: Record<string, string>, ask: (message: string) => Promise<boolean>): Promise<void>;
@@ -0,0 +1,183 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { styleText } from 'node:util';
4
+ import { contentHash, readFileHash } from '../init-version.js';
5
+ import { findTemplatesDir } from './templates.js';
6
+ /**
7
+ * The fork's own instruction channel into a consumer project.
8
+ *
9
+ * Upstream teaches a consumer's agent through `templates/AGENTS.md` and
10
+ * `templates/skill/SKILL.md`. Both are upstream files, and both are the ones
11
+ * upstream is most likely to rewrite, so every sentence this fork adds to them
12
+ * is a merge conflict bought on credit. The fork's Diátaxis block cost 13
13
+ * lines in one and 14 in the other before this module existed.
14
+ *
15
+ * Everything here writes into fork-owned paths instead. The entire upstream
16
+ * footprint is one import and one call in `initCmd`.
17
+ *
18
+ * Two shapes, because the destinations behave differently:
19
+ *
20
+ * - Always-loaded instruction files (`CLAUDE.md`, `AGENTS.md`,
21
+ * `.github/copilot-instructions.md`) already carry upstream's
22
+ * `%% lat:begin %%` block, so this appends a *second* block under its own
23
+ * `%% lat-fork:begin %%` markers. Upstream's `appendTemplateSection` slices
24
+ * between its own markers and leaves anything after them intact, so the two
25
+ * blocks do not fight on re-run.
26
+ * - Skill directories get a separate `lat-md-conventions/SKILL.md`. Upstream
27
+ * writes its `lat-md` skill with a whole-file hash comparison, so appending
28
+ * to that file would make every later `lat init` report it as
29
+ * user-modified and prompt.
30
+ */
31
+ const FORK_BEGIN = '%% lat-fork:begin %%';
32
+ const FORK_END = '%% lat-fork:end %%';
33
+ /**
34
+ * Hash-map key for a fork block inside a file upstream also owns. Suffixed so
35
+ * it can never collide with upstream's entry for the same path — both live in
36
+ * one flat `file_hashes` record.
37
+ */
38
+ function forkHashKey(relPath) {
39
+ return `${relPath}#lat-fork`;
40
+ }
41
+ /** Instruction files that use upstream's marker-append shape. */
42
+ const BLOCK_TARGETS = [
43
+ 'CLAUDE.md',
44
+ 'AGENTS.md',
45
+ '.github/copilot-instructions.md',
46
+ ];
47
+ /**
48
+ * Skill roots every agent integration installs into. Written to only when the
49
+ * directory already exists, which is what keeps this list from creating skill
50
+ * directories for agents the user did not select.
51
+ */
52
+ const SKILL_ROOTS = [
53
+ '.claude/skills',
54
+ '.pi/skills',
55
+ '.agents/skills',
56
+ '.codex/skills',
57
+ ];
58
+ const SKILL_NAME = 'lat-md-conventions';
59
+ const SKILL_FRONTMATTER = `---
60
+ name: ${SKILL_NAME}
61
+ description: >-
62
+ Rules lat check enforces in this project: Diátaxis mode placement for every
63
+ lat.md/ document, mandatory @lat: code refs starting at the application
64
+ entrypoint, and test specs wired to their tests. Load alongside lat-md
65
+ whenever creating or editing files under lat.md/.
66
+ ---
67
+
68
+ `;
69
+ export function readForkConventions() {
70
+ return readFileSync(join(findTemplatesDir(), 'fork', 'conventions.md'), 'utf-8');
71
+ }
72
+ function wrapBlock(body) {
73
+ return `${FORK_BEGIN}\n${body}${body.endsWith('\n') ? '' : '\n'}${FORK_END}\n`;
74
+ }
75
+ /**
76
+ * Replace the fork block in `content`, or append one when absent.
77
+ *
78
+ * Returns null when the block is already byte-identical, so the caller can
79
+ * stay quiet rather than announce a write that did not happen.
80
+ */
81
+ export function spliceForkBlock(content, body) {
82
+ const wrapped = wrapBlock(body);
83
+ const begin = content.indexOf(FORK_BEGIN);
84
+ const end = content.indexOf(FORK_END);
85
+ if (begin === -1 || end === -1 || end <= begin) {
86
+ let base = content;
87
+ if (base.length > 0 && !base.endsWith('\n'))
88
+ base += '\n';
89
+ return base + (base.length > 0 ? '\n' : '') + wrapped;
90
+ }
91
+ const endWithMarker = end + FORK_END.length;
92
+ const endWithNl = content[endWithMarker] === '\n' ? endWithMarker + 1 : endWithMarker;
93
+ const existing = content.slice(begin, endWithNl);
94
+ if (existing === wrapped)
95
+ return null;
96
+ return content.slice(0, begin) + wrapped + content.slice(endWithNl);
97
+ }
98
+ /** Read the body currently fenced by the fork markers, or null if absent. */
99
+ export function extractForkBlock(content) {
100
+ const begin = content.indexOf(FORK_BEGIN);
101
+ const end = content.indexOf(FORK_END);
102
+ if (begin === -1 || end === -1 || end <= begin)
103
+ return null;
104
+ return content.slice(begin + FORK_BEGIN.length + 1, end);
105
+ }
106
+ /**
107
+ * Write the fork's conventions into every instruction file and skill directory
108
+ * the selected agents produced.
109
+ *
110
+ * `fileHashes` is upstream's record, threaded through so `writeInitMeta`
111
+ * persists these entries alongside its own; a stored hash is what lets a later
112
+ * run tell "the user edited our block" from "the template moved on".
113
+ */
114
+ export async function writeForkInstructions(root, latDir, fileHashes, ask) {
115
+ const body = readForkConventions();
116
+ const bodyHash = contentHash(body);
117
+ const written = [];
118
+ for (const relPath of BLOCK_TARGETS) {
119
+ const absPath = join(root, relPath);
120
+ if (!existsSync(absPath))
121
+ continue;
122
+ const current = readFileSync(absPath, 'utf-8');
123
+ const existingBlock = extractForkBlock(current);
124
+ if (existingBlock !== null) {
125
+ const existingHash = contentHash(existingBlock);
126
+ if (existingHash === bodyHash) {
127
+ fileHashes[forkHashKey(relPath)] = bodyHash;
128
+ continue;
129
+ }
130
+ const storedHash = readFileHash(latDir, forkHashKey(relPath));
131
+ if (storedHash && existingHash !== storedHash) {
132
+ console.log(styleText('yellow', ` ${relPath}`) +
133
+ ' lat-fork section has been modified.');
134
+ if (!(await ask(' Replace lat-fork section with latest template?'))) {
135
+ console.log(styleText('dim', ' Kept existing section.'));
136
+ continue;
137
+ }
138
+ }
139
+ }
140
+ const updated = spliceForkBlock(current, body);
141
+ if (updated === null) {
142
+ fileHashes[forkHashKey(relPath)] = bodyHash;
143
+ continue;
144
+ }
145
+ writeFileSync(absPath, updated);
146
+ fileHashes[forkHashKey(relPath)] = bodyHash;
147
+ written.push(relPath);
148
+ }
149
+ const skillBody = SKILL_FRONTMATTER + body;
150
+ const skillHash = contentHash(skillBody);
151
+ for (const skillRoot of SKILL_ROOTS) {
152
+ const rootAbs = join(root, skillRoot);
153
+ if (!existsSync(rootAbs))
154
+ continue;
155
+ const relPath = `${skillRoot}/${SKILL_NAME}/SKILL.md`;
156
+ const absPath = join(root, relPath);
157
+ if (existsSync(absPath)) {
158
+ const current = readFileSync(absPath, 'utf-8');
159
+ if (contentHash(current) === skillHash) {
160
+ fileHashes[relPath] = skillHash;
161
+ continue;
162
+ }
163
+ const storedHash = readFileHash(latDir, relPath);
164
+ if (storedHash && contentHash(current) !== storedHash) {
165
+ console.log(styleText('yellow', ` ${relPath}`) + ' has been modified.');
166
+ if (!(await ask(' Overwrite with latest lat template?'))) {
167
+ console.log(styleText('dim', ' Kept existing file.'));
168
+ continue;
169
+ }
170
+ }
171
+ }
172
+ mkdirSync(join(absPath, '..'), { recursive: true });
173
+ writeFileSync(absPath, skillBody);
174
+ fileHashes[relPath] = skillHash;
175
+ written.push(relPath);
176
+ }
177
+ if (written.length > 0) {
178
+ console.log('');
179
+ console.log(styleText('green', ' lat.md conventions') +
180
+ ' written to ' +
181
+ written.join(', '));
182
+ }
183
+ }
@@ -0,0 +1,16 @@
1
+ /** Prepend the `require-mode` flag to a root index that has no frontmatter. */
2
+ export declare function stampRequireMode(content: string): string;
3
+ /**
4
+ * Append the four mode directories to the root index.
5
+ *
6
+ * Without this a fresh `lat init` leaves `lat check index` reporting four
7
+ * missing entries, which is a poor first impression of a tool whose whole
8
+ * pitch is that the check passes.
9
+ */
10
+ export declare function listModeDirs(content: string): string;
11
+ /**
12
+ * Scaffold the four mode directories and turn the gate on. Called only on the
13
+ * run that creates `lat.md/`, so an existing tree is never restructured
14
+ * underneath its owner.
15
+ */
16
+ export declare function writeForkScaffold(latDir: string): void;
@@ -0,0 +1,129 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { styleText } from 'node:util';
4
+ import { DIATAXIS_MODES, MODE_DIRS, indexNameFor } from './check-mode.js';
5
+ /**
6
+ * Give a freshly created `lat.md/` the Diátaxis shape, and turn the gate on.
7
+ *
8
+ * Upstream's `templates/init/` holds two files and is copied wholesale, so a
9
+ * new tree is one flat root index. An agent asked to document a codebase then
10
+ * writes flat files beside it — which is not merely untidy: `checkMode` binds
11
+ * only inside the four mode directories, so flat placement puts every document
12
+ * out of reach of every shape rule. A bella-derms session did exactly that and
13
+ * confirmed afterwards that its largest document produced 64 errors the moment
14
+ * it was moved into `reference/`.
15
+ *
16
+ * Structure is the cheaper half of the fix. Four directories that already
17
+ * exist, each with a passing index, make placement the path of least
18
+ * resistance; `require-mode: true` in the root index makes flat placement an
19
+ * error rather than an option.
20
+ *
21
+ * Written from here rather than added to `templates/init/` because the flag
22
+ * has to land inside `lat.md/lat.md`, which upstream's template owns.
23
+ */
24
+ /** One-line summary and body for each mode's index, keyed by directory. */
25
+ const MODE_INDEX = {
26
+ tutorials: {
27
+ title: 'Tutorials',
28
+ lead: 'Guided first passes through this system, each ending somewhere the reader can see.',
29
+ },
30
+ 'how-to': {
31
+ title: 'How-to',
32
+ lead: 'Recipes for tasks that recur, written for someone who already knows what they want.',
33
+ },
34
+ reference: {
35
+ title: 'Reference',
36
+ lead: 'Lookup facts about this system: fields, states, limits, names and defaults.',
37
+ },
38
+ explanation: {
39
+ title: 'Explanation',
40
+ lead: 'Why this system is shaped the way it is, and what the alternatives cost.',
41
+ },
42
+ };
43
+ /**
44
+ * A mode index has to satisfy its own mode. A tutorial index needs ordered
45
+ * steps and a stated outcome, a how-to index needs ordered steps, and a
46
+ * reference index must not carry a second paragraph — so each stub is written
47
+ * to the rule it will be checked against rather than to one house style.
48
+ */
49
+ function indexBody(dir) {
50
+ const { title, lead } = MODE_INDEX[dir];
51
+ const header = `# ${title}\n\n${lead}\n`;
52
+ if (dir === 'tutorials') {
53
+ return (header +
54
+ '\nBy the end of any document here the reader will have run something and seen it work.\n' +
55
+ '\n1. Pick the tutorial closest to what you are trying to learn.\n' +
56
+ '2. Follow it top to bottom without skipping.\n' +
57
+ '3. Come back to `reference/` for the details it left out.\n');
58
+ }
59
+ if (dir === 'how-to') {
60
+ return (header +
61
+ '\n1. Find the recipe for the task in front of you.\n' +
62
+ '2. Follow its steps.\n' +
63
+ '3. If no recipe fits, write one here once the task is done.\n');
64
+ }
65
+ if (dir === 'reference') {
66
+ return header;
67
+ }
68
+ return (header +
69
+ '\nEach document here answers a "why", not a "how". The steps that follow ' +
70
+ 'from a decision belong in `how-to/`, and the facts it fixes belong in ' +
71
+ '`reference/`.\n');
72
+ }
73
+ /** Prepend the `require-mode` flag to a root index that has no frontmatter. */
74
+ export function stampRequireMode(content) {
75
+ if (/^---\n/.test(content))
76
+ return content;
77
+ return `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
78
+ }
79
+ /**
80
+ * Append the four mode directories to the root index.
81
+ *
82
+ * Without this a fresh `lat init` leaves `lat check index` reporting four
83
+ * missing entries, which is a poor first impression of a tool whose whole
84
+ * pitch is that the check passes.
85
+ */
86
+ export function listModeDirs(content) {
87
+ const entries = DIATAXIS_MODES.map((mode) => {
88
+ const dir = MODE_DIRS[mode];
89
+ const { title, lead } = MODE_INDEX[dir];
90
+ return `- [${title}](${dir}/${dir}.md) — ${lead}`;
91
+ }).join('\n');
92
+ if (content.includes(`(${MODE_DIRS.reference}/`))
93
+ return content;
94
+ let base = content;
95
+ if (!base.endsWith('\n'))
96
+ base += '\n';
97
+ return `${base}\nEvery document belongs in exactly one of these.\n\n${entries}\n`;
98
+ }
99
+ /**
100
+ * Scaffold the four mode directories and turn the gate on. Called only on the
101
+ * run that creates `lat.md/`, so an existing tree is never restructured
102
+ * underneath its owner.
103
+ */
104
+ export function writeForkScaffold(latDir) {
105
+ const created = [];
106
+ for (const mode of DIATAXIS_MODES) {
107
+ const dir = MODE_DIRS[mode];
108
+ const dirPath = join(latDir, dir);
109
+ const indexPath = join(dirPath, `${dir}.md`);
110
+ if (existsSync(indexPath))
111
+ continue;
112
+ mkdirSync(dirPath, { recursive: true });
113
+ writeFileSync(indexPath, indexBody(dir));
114
+ created.push(`${dir}/`);
115
+ }
116
+ const rootIndex = join(latDir, indexNameFor(basename(latDir)));
117
+ if (existsSync(rootIndex)) {
118
+ const current = readFileSync(rootIndex, 'utf-8');
119
+ const stamped = listModeDirs(stampRequireMode(current));
120
+ if (stamped !== current)
121
+ writeFileSync(rootIndex, stamped);
122
+ }
123
+ if (created.length > 0) {
124
+ console.log(styleText('green', 'Scaffolded Diátaxis modes') +
125
+ ' — ' +
126
+ created.join(' ') +
127
+ styleText('dim', ' (every document belongs in one of these)'));
128
+ }
129
+ }
@@ -6,8 +6,18 @@ if (!process.argv.includes('--verbose')) {
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
7
  import { dirname, join } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { Command } from 'commander';
9
+ import { Command, InvalidArgumentError } from 'commander';
10
10
  import { resolveCheckContext, resolveContext } from './context.js';
11
+ function parsePort(value) {
12
+ if (!/^\d+$/.test(value)) {
13
+ throw new InvalidArgumentError('port must be an integer from 1 to 65535');
14
+ }
15
+ const port = Number(value);
16
+ if (port < 1 || port > 65_535) {
17
+ throw new InvalidArgumentError('port must be an integer from 1 to 65535');
18
+ }
19
+ return port;
20
+ }
11
21
  /** Reserve `-- <directory>` for an explicit check target. */
12
22
  function splitCheckTarget(args) {
13
23
  let commandIndex = -1;
@@ -90,13 +100,28 @@ program
90
100
  const { sectionCommand } = await import('./section.js');
91
101
  handleResult(await sectionCommand(ctx, query));
92
102
  });
93
- program
103
+ const ui = program
94
104
  .command('ui')
95
105
  .description('Open lat.md in a local browser')
96
- .action(async () => {
106
+ .option('--logo-text <text>', 'top-left logo text')
107
+ .option('--port <number>', 'server port (default: 4242; explicit ports are strict)', parsePort)
108
+ .action(async (opts) => {
97
109
  const ctx = resolveContext(program.opts());
98
110
  const { uiCommand } = await import('./ui.js');
99
- handleResult(await uiCommand(ctx));
111
+ handleResult(await uiCommand(ctx, { logoText: opts.logoText, port: opts.port }));
112
+ });
113
+ ui.command('build')
114
+ .description('Export lat.md as a static website')
115
+ .argument('[output]', 'output directory relative to the project', 'lat-ui')
116
+ .option('--base <path>', 'deployment base path', '/')
117
+ .option('--logo-text <text>', 'top-left logo text')
118
+ .action(async (output, opts) => {
119
+ const ctx = resolveContext(program.opts());
120
+ const { uiBuildCommand } = await import('./ui-build.js');
121
+ handleResult(await uiBuildCommand(ctx, output, {
122
+ basePath: opts.base,
123
+ logoText: opts.logoText ?? ui.opts().logoText,
124
+ }));
100
125
  });
101
126
  program
102
127
  .command('refs')
@@ -15,6 +15,8 @@ import { INIT_VERSION, writeInitMeta, readInitVersion, readFileHash, contentHash
15
15
  import { getLocalVersion, fetchLatestVersion } from '../version.js';
16
16
  import { selectMenu } from './select-menu.js';
17
17
  import { checklistMenu } from './checklist-menu.js';
18
+ import { writeForkInstructions } from './fork-instructions.js';
19
+ import { writeForkScaffold } from './fork-scaffold.js';
18
20
  async function confirm(rl, message) {
19
21
  while (true) {
20
22
  let answer;
@@ -900,6 +902,7 @@ export async function initCmd(targetDir) {
900
902
  mkdirSync(latDir, { recursive: true });
901
903
  cpSync(templateDir, latDir, { recursive: true });
902
904
  console.log(styleText('green', 'Created lat.md/'));
905
+ writeForkScaffold(latDir);
903
906
  }
904
907
  // Step 2: Configure fresh/outdated setups, ask interactive users about an
905
908
  // available key, and offer to rebuild an index whose backend differs. This
@@ -1004,6 +1007,7 @@ export async function initCmd(targetDir) {
1004
1007
  console.log(styleText('bold', 'Setting up Codex...'));
1005
1008
  await setupCodex(root, latDir, fileHashes, ask, commandStyle);
1006
1009
  }
1010
+ await writeForkInstructions(root, latDir, fileHashes, ask);
1007
1011
  // Record init version and file hashes so `lat check` can detect stale setups
1008
1012
  writeInitMeta(latDir, fileHashes);
1009
1013
  console.log('');
@@ -73,10 +73,12 @@ async function resolveMatches(latDir, results, preloadedSections) {
73
73
  const allSections = preloadedSections ?? (await loadAllSections(latDir));
74
74
  const flat = flattenSections(allSections);
75
75
  const byId = new Map(flat.map((s) => [s.id, s]));
76
- return results
77
- .map((r) => byId.get(r.id))
78
- .filter((s) => !!s)
79
- .map((s) => ({ section: s, reason: 'semantic match' }));
76
+ return results.flatMap((result) => {
77
+ const section = byId.get(result.id);
78
+ return section
79
+ ? [{ section, reason: 'semantic match', score: result.score }]
80
+ : [];
81
+ });
80
82
  }
81
83
  /**
82
84
  * Run a semantic search across lat.md sections.
@@ -0,0 +1,4 @@
1
+ import type { CmdContext, CmdResult } from '../context.js';
2
+ import { type StaticViewBuildOptions } from '../view/static-build.js';
3
+ /** Export the current vault as a serverless, read-only Lat UI. */
4
+ export declare function uiBuildCommand(ctx: CmdContext, output?: string, options?: StaticViewBuildOptions): Promise<CmdResult>;
@@ -0,0 +1,13 @@
1
+ import { buildStaticView, } from '../view/static-build.js';
2
+ /** Export the current vault as a serverless, read-only Lat UI. */
3
+ export async function uiBuildCommand(ctx, output = 'lat-ui', options = {}) {
4
+ try {
5
+ const result = await buildStaticView(ctx, output, options);
6
+ return {
7
+ output: `Built ${result.documents} documents and ${result.sources} source views at ${result.outputDir}`,
8
+ };
9
+ }
10
+ catch (error) {
11
+ return { output: error.message, isError: true };
12
+ }
13
+ }