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

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.
@@ -1,5 +1,19 @@
1
- /** Prepend the `require-mode` flag to a root index that has no frontmatter. */
1
+ /**
2
+ * Turn the gate on in a root index, whatever frontmatter it already has.
3
+ *
4
+ * Bailing on any existing frontmatter — the first version of this — produced a
5
+ * silent no-op: a root index carrying `lat: tags: [...]` could never opt in,
6
+ * and `lat init` re-offered on every run because the flag it had just written
7
+ * was not there. Merging is what makes the offer terminate.
8
+ *
9
+ * Edited line by line rather than parsed and re-emitted, because re-emitting
10
+ * reformats the whole block and discards its comments. The one shape that
11
+ * cannot be edited safely — `lat:` carrying a flow mapping on its own line —
12
+ * is returned unchanged, and the caller falls back to printing the edit.
13
+ */
2
14
  export declare function stampRequireMode(content: string): string;
15
+ /** Whether the gate is on, read straight from a root index's frontmatter. */
16
+ export declare function requireModeSet(content: string): boolean;
3
17
  /**
4
18
  * Append the four mode directories to the root index.
5
19
  *
@@ -14,3 +28,17 @@ export declare function listModeDirs(content: string): string;
14
28
  * underneath its owner.
15
29
  */
16
30
  export declare function writeForkScaffold(latDir: string): void;
31
+ /**
32
+ * Offer the gate to a tree `lat init` did not create.
33
+ *
34
+ * `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
35
+ * left the flag unreachable for every project that already had one — that is,
36
+ * for every project that needs it. Restructuring someone's tree without asking
37
+ * is still wrong, so this asks.
38
+ *
39
+ * Silent when the flag is already set, when a previous run was told no, and
40
+ * when there is no TTY to ask — the non-interactive path prints the count and
41
+ * the manual edit instead, and records nothing, so a later interactive run
42
+ * still offers.
43
+ */
44
+ export declare function offerRequireMode(latDir: string, interactive: boolean, ask: (message: string) => Promise<boolean>): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
2
+ import { listLatticeFiles, parseFrontmatter } from '../lattice.js';
3
+ import { basename, join, relative, sep } from 'node:path';
3
4
  import { styleText } from 'node:util';
4
5
  import { DIATAXIS_MODES, MODE_DIRS, indexNameFor } from './check-mode.js';
5
6
  /**
@@ -70,11 +71,57 @@ function indexBody(dir) {
70
71
  'from a decision belong in `how-to/`, and the facts it fixes belong in ' +
71
72
  '`reference/`.\n');
72
73
  }
73
- /** Prepend the `require-mode` flag to a root index that has no frontmatter. */
74
+ /**
75
+ * Turn the gate on in a root index, whatever frontmatter it already has.
76
+ *
77
+ * Bailing on any existing frontmatter — the first version of this — produced a
78
+ * silent no-op: a root index carrying `lat: tags: [...]` could never opt in,
79
+ * and `lat init` re-offered on every run because the flag it had just written
80
+ * was not there. Merging is what makes the offer terminate.
81
+ *
82
+ * Edited line by line rather than parsed and re-emitted, because re-emitting
83
+ * reformats the whole block and discards its comments. The one shape that
84
+ * cannot be edited safely — `lat:` carrying a flow mapping on its own line —
85
+ * is returned unchanged, and the caller falls back to printing the edit.
86
+ */
74
87
  export function stampRequireMode(content) {
75
- if (/^---\n/.test(content))
88
+ const fence = content.match(/^---\n([\s\S]*?)\n---[ \t]*(\r?\n|$)/);
89
+ if (!fence) {
90
+ return `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
91
+ }
92
+ const body = fence[1];
93
+ if (/^\s*require-mode\s*:/m.test(body))
76
94
  return content;
77
- return `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
95
+ const lines = body.split('\n');
96
+ const latAt = lines.findIndex((line) => /^lat\s*:/.test(line));
97
+ let merged;
98
+ if (latAt === -1) {
99
+ merged = [...lines, 'lat:', ' require-mode: true'];
100
+ }
101
+ else if (lines[latAt].replace(/^lat\s*:/, '').trim() !== '') {
102
+ // `lat: {mode: reference}` or `lat: &anchor` — a line-based insert would
103
+ // corrupt it, so say nothing and let the caller print the edit.
104
+ return content;
105
+ }
106
+ else {
107
+ // Siblings must share indentation, so take it from the existing first
108
+ // child rather than assuming two spaces.
109
+ const child = lines
110
+ .slice(latAt + 1)
111
+ .find((line) => line.trim() !== '' && /^\s/.test(line));
112
+ const indent = child ? (child.match(/^\s+/)?.[0] ?? ' ') : ' ';
113
+ merged = [
114
+ ...lines.slice(0, latAt + 1),
115
+ `${indent}require-mode: true`,
116
+ ...lines.slice(latAt + 1),
117
+ ];
118
+ }
119
+ const end = fence[0].length;
120
+ return `---\n${merged.join('\n')}\n---${fence[2] || '\n'}${content.slice(end)}`;
121
+ }
122
+ /** Whether the gate is on, read straight from a root index's frontmatter. */
123
+ export function requireModeSet(content) {
124
+ return parseFrontmatter(content).raw['require-mode'] !== undefined;
78
125
  }
79
126
  /**
80
127
  * Append the four mode directories to the root index.
@@ -127,3 +174,127 @@ export function writeForkScaffold(latDir) {
127
174
  styleText('dim', ' (every document belongs in one of these)'));
128
175
  }
129
176
  }
177
+ // ── Adopting the gate in a tree that already exists ──────────────────
178
+ /**
179
+ * Where a declined offer is remembered.
180
+ *
181
+ * Fork-owned rather than a field on upstream's `lat_init.json`, so recording
182
+ * it costs no edit to `src/init-version.ts`. `lat.md/.cache/` is already in
183
+ * the scaffolded `.gitignore`, so the marker stays out of the repository.
184
+ */
185
+ function declinePath(latDir) {
186
+ return join(latDir, '.cache', 'lat_fork.json');
187
+ }
188
+ function declined(latDir) {
189
+ try {
190
+ const raw = JSON.parse(readFileSync(declinePath(latDir), 'utf-8'));
191
+ return (typeof raw === 'object' &&
192
+ raw !== null &&
193
+ raw['require_mode_declined'] === true);
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ }
199
+ function recordDecline(latDir) {
200
+ mkdirSync(join(latDir, '.cache'), { recursive: true });
201
+ writeFileSync(declinePath(latDir), JSON.stringify({ require_mode_declined: true }, null, 2) + '\n');
202
+ }
203
+ /** Documents the gate would reject today: not an index, no mode either way. */
204
+ async function unmodedDocuments(latDir) {
205
+ const modeDirs = new Set(DIATAXIS_MODES.map((mode) => MODE_DIRS[mode]));
206
+ const files = await listLatticeFiles(latDir);
207
+ const offenders = [];
208
+ for (const file of files) {
209
+ const rel = relative(latDir, file).split(sep).join('/');
210
+ const segments = rel.split('/');
211
+ const fileName = segments.pop();
212
+ const holder = segments.length === 0 ? basename(latDir) : segments.at(-1);
213
+ if (fileName === indexNameFor(holder))
214
+ continue;
215
+ if (segments.length > 0 && modeDirs.has(segments[0]))
216
+ continue;
217
+ const declaredMode = parseFrontmatter(readFileSync(file, 'utf-8')).raw['mode'];
218
+ if (typeof declaredMode === 'string')
219
+ continue;
220
+ offenders.push(rel);
221
+ }
222
+ return offenders;
223
+ }
224
+ /**
225
+ * Offer the gate to a tree `lat init` did not create.
226
+ *
227
+ * `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
228
+ * left the flag unreachable for every project that already had one — that is,
229
+ * for every project that needs it. Restructuring someone's tree without asking
230
+ * is still wrong, so this asks.
231
+ *
232
+ * Silent when the flag is already set, when a previous run was told no, and
233
+ * when there is no TTY to ask — the non-interactive path prints the count and
234
+ * the manual edit instead, and records nothing, so a later interactive run
235
+ * still offers.
236
+ */
237
+ export async function offerRequireMode(latDir, interactive, ask) {
238
+ const rootIndex = join(latDir, indexNameFor(basename(latDir)));
239
+ if (!existsSync(rootIndex))
240
+ return;
241
+ if (requireModeSet(readFileSync(rootIndex, 'utf-8')))
242
+ return;
243
+ if (declined(latDir))
244
+ return;
245
+ const offenders = await unmodedDocuments(latDir);
246
+ console.log('');
247
+ console.log(styleText('bold', 'Diátaxis modes'));
248
+ console.log(' ' +
249
+ styleText('dim', 'Every document belongs in one of four modes. Without') +
250
+ ' require-mode ' +
251
+ styleText('dim', 'a document'));
252
+ console.log(' ' +
253
+ styleText('dim', 'placed outside a mode directory is checked against no shape rule at all.'));
254
+ if (offenders.length > 0) {
255
+ console.log('');
256
+ console.log(` ${styleText('yellow', String(offenders.length))} document(s) would need a mode:`);
257
+ for (const name of offenders.slice(0, 5)) {
258
+ console.log(' ' + styleText('dim', name));
259
+ }
260
+ if (offenders.length > 5) {
261
+ console.log(' ' + styleText('dim', `... and ${offenders.length - 5} more`));
262
+ }
263
+ }
264
+ if (!interactive) {
265
+ console.log('');
266
+ console.log(' ' +
267
+ styleText('dim', 'To turn it on, add this to the top of') +
268
+ ` ${basename(latDir)}/${indexNameFor(basename(latDir))}:`);
269
+ console.log('');
270
+ for (const line of ['---', 'lat:', ' require-mode: true', '---']) {
271
+ console.log(' ' + styleText('cyan', line));
272
+ }
273
+ return;
274
+ }
275
+ console.log('');
276
+ if (!(await ask(' Turn require-mode on for this project?'))) {
277
+ recordDecline(latDir);
278
+ console.log(' ' +
279
+ styleText('dim', 'Skipped. Add') +
280
+ ' require-mode: true ' +
281
+ styleText('dim', 'to the root index whenever you want it.'));
282
+ return;
283
+ }
284
+ writeForkScaffold(latDir);
285
+ // The one frontmatter shape `stampRequireMode` refuses to edit. Saying so
286
+ // beats re-offering forever on a flag that never lands.
287
+ if (!requireModeSet(readFileSync(rootIndex, 'utf-8'))) {
288
+ console.log(' ' +
289
+ styleText('yellow', 'Could not edit the frontmatter safely.') +
290
+ styleText('dim', ' Add this under its `lat:` mapping by hand:'));
291
+ console.log(' ' + styleText('cyan', 'require-mode: true'));
292
+ return;
293
+ }
294
+ if (offenders.length > 0) {
295
+ console.log(' ' +
296
+ styleText('dim', 'Run') +
297
+ ' lat check ' +
298
+ styleText('dim', 'to see which documents need placing.'));
299
+ }
300
+ }
@@ -16,7 +16,7 @@ import { getLocalVersion, fetchLatestVersion } from '../version.js';
16
16
  import { selectMenu } from './select-menu.js';
17
17
  import { checklistMenu } from './checklist-menu.js';
18
18
  import { writeForkInstructions } from './fork-instructions.js';
19
- import { writeForkScaffold } from './fork-scaffold.js';
19
+ import { offerRequireMode, writeForkScaffold } from './fork-scaffold.js';
20
20
  async function confirm(rl, message) {
21
21
  while (true) {
22
22
  let answer;
@@ -957,6 +957,7 @@ export async function initCmd(targetDir) {
957
957
  output: process.stdout,
958
958
  });
959
959
  }
960
+ await offerRequireMode(latDir, interactive, ask);
960
961
  if (!anySelected) {
961
962
  // Embedding setup is complete even when the user does not configure an
962
963
  // agent. Stamp the version so future non-interactive runs do not reapply
@@ -3,7 +3,7 @@
3
3
  * requires users to re-run it (e.g. new hooks, generated files, MCP config, or
4
4
  * a configuration migration).
5
5
  */
6
- export declare const INIT_VERSION = 6;
6
+ export declare const INIT_VERSION = 7;
7
7
  export declare function readInitVersion(latDir: string): number | null;
8
8
  export declare function readFileHash(latDir: string, relPath: string): string | null;
9
9
  export declare function contentHash(content: string): string;
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  * requires users to re-run it (e.g. new hooks, generated files, MCP config, or
7
7
  * a configuration migration).
8
8
  */
9
- export const INIT_VERSION = 6;
9
+ export const INIT_VERSION = 7;
10
10
  function cachePath(latDir) {
11
11
  return join(latDir, '.cache', 'lat_init.json');
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plebeiantech/lat.md",
3
- "version": "0.12.3-fork.6",
3
+ "version": "0.12.3-fork.7",
4
4
  "description": "A knowledge graph for your codebase, written in markdown",
5
5
  "type": "module",
6
6
  "license": "MIT",