@plebeiantech/lat.md 0.12.3-fork.6 → 0.12.3-fork.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/cli/check-mode.d.ts +10 -1
- package/dist/src/cli/check-mode.js +21 -6
- package/dist/src/cli/fork-scaffold.d.ts +95 -5
- package/dist/src/cli/fork-scaffold.js +383 -22
- package/dist/src/cli/init.js +2 -1
- package/dist/src/init-version.d.ts +1 -1
- package/dist/src/init-version.js +1 -1
- package/package.json +6 -6
|
@@ -9,4 +9,13 @@ export declare const MODE_DIRS: Record<DiataxisMode, string>;
|
|
|
9
9
|
* content, so the `require-mode` gate below never asks one to declare a mode.
|
|
10
10
|
*/
|
|
11
11
|
export declare function indexNameFor(dirName: string): string;
|
|
12
|
-
export
|
|
12
|
+
export type CheckModeOptions = {
|
|
13
|
+
/**
|
|
14
|
+
* Enforce as if the root index said this, whatever it actually says. Used to
|
|
15
|
+
* price adoption — running once each way and taking the difference is what
|
|
16
|
+
* the offer in `lat init` reports, and it keeps that number defined by this
|
|
17
|
+
* checker rather than by a second copy of its rules.
|
|
18
|
+
*/
|
|
19
|
+
requireMode?: boolean;
|
|
20
|
+
};
|
|
21
|
+
export declare function checkMode(latticeDir: string, projectRoot?: string, options?: CheckModeOptions): Promise<CheckError[]>;
|
|
@@ -190,7 +190,7 @@ export function indexNameFor(dirName) {
|
|
|
190
190
|
return dirName.endsWith('.md') ? dirName : dirName + '.md';
|
|
191
191
|
}
|
|
192
192
|
/**
|
|
193
|
-
*
|
|
193
|
+
* What the root index says about the mode requirement.
|
|
194
194
|
*
|
|
195
195
|
* Opt-in, and deliberately read from the tree rather than from a flag or an
|
|
196
196
|
* environment variable: the rule belongs to a documentation set, not to a
|
|
@@ -198,21 +198,36 @@ export function indexNameFor(dirName) {
|
|
|
198
198
|
* `lat init` stamps the flag into the root index it scaffolds, so a project
|
|
199
199
|
* set up after this exists is gated from its first commit while an older one
|
|
200
200
|
* is untouched until someone adds the line.
|
|
201
|
+
*
|
|
202
|
+
* The raw value is returned rather than a boolean so the caller can tell an
|
|
203
|
+
* absent flag from one that was set to something this never reads. `yes` and
|
|
204
|
+
* `1` are strings to a YAML 1.2 parser, and enforcing nothing on them while
|
|
205
|
+
* saying nothing about them is how a project ends up believing the gate is on.
|
|
201
206
|
*/
|
|
202
|
-
async function
|
|
207
|
+
async function readRequireModeFlag(latticeDir) {
|
|
203
208
|
const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
|
|
204
209
|
try {
|
|
205
210
|
const content = await readFile(indexPath, 'utf-8');
|
|
206
|
-
return parseFrontmatter(content).raw['require-mode']
|
|
211
|
+
return parseFrontmatter(content).raw['require-mode'];
|
|
207
212
|
}
|
|
208
213
|
catch {
|
|
209
|
-
return
|
|
214
|
+
return undefined;
|
|
210
215
|
}
|
|
211
216
|
}
|
|
212
|
-
export async function checkMode(latticeDir, projectRoot = dirname(latticeDir)) {
|
|
217
|
+
export async function checkMode(latticeDir, projectRoot = dirname(latticeDir), options = {}) {
|
|
213
218
|
const files = await listLatticeFiles(latticeDir);
|
|
214
219
|
const errors = [];
|
|
215
|
-
const
|
|
220
|
+
const flag = await readRequireModeFlag(latticeDir);
|
|
221
|
+
const requireMode = options.requireMode ?? flag === true;
|
|
222
|
+
if (flag !== undefined && typeof flag !== 'boolean') {
|
|
223
|
+
const indexPath = join(latticeDir, indexNameFor(basename(latticeDir)));
|
|
224
|
+
errors.push({
|
|
225
|
+
file: relative(process.cwd(), indexPath),
|
|
226
|
+
line: 1,
|
|
227
|
+
target: toPosix(relative(projectRoot, indexPath)).replace(/\.md$/, ''),
|
|
228
|
+
message: `require-mode is ${JSON.stringify(flag)} — it must be true or false.\n Anything else is enforced as off, so the gate this line looks like it turns on is not running.`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
216
231
|
for (const file of files) {
|
|
217
232
|
const content = await readFile(file, 'utf-8');
|
|
218
233
|
const relPath = relative(process.cwd(), file);
|
|
@@ -1,16 +1,106 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** What the root index says about the gate today. */
|
|
2
|
+
export type RequireModeState = 'on' | 'off' | 'unset' | 'invalid';
|
|
3
|
+
/**
|
|
4
|
+
* Read the gate straight from a root index's frontmatter.
|
|
5
|
+
*
|
|
6
|
+
* Four states rather than a boolean, because `checkMode` enforces on `=== true`
|
|
7
|
+
* and anything looser here disagrees with it. A root index reading
|
|
8
|
+
* `require-mode: yes` is a string to this parser: treating that as "set" left
|
|
9
|
+
* the user believing the gate was on, `checkMode` enforcing nothing, and the
|
|
10
|
+
* one prompt that would have said so permanently silenced by the same value.
|
|
11
|
+
* `invalid` is reported rather than assumed either way.
|
|
12
|
+
*/
|
|
13
|
+
export declare function requireModeState(content: string): RequireModeState;
|
|
14
|
+
/** What `planRequireMode` decided, before anything is written or asked. */
|
|
15
|
+
export type StampPlan =
|
|
16
|
+
/** Already `true`; nothing to do. */
|
|
17
|
+
{
|
|
18
|
+
kind: 'on';
|
|
19
|
+
}
|
|
20
|
+
/** Explicitly `false`. A deliberate opt-out is not re-offered. */
|
|
21
|
+
| {
|
|
22
|
+
kind: 'off';
|
|
23
|
+
}
|
|
24
|
+
/** The edited document, ready to write. */
|
|
25
|
+
| {
|
|
26
|
+
kind: 'stamped';
|
|
27
|
+
content: string;
|
|
28
|
+
movedRootKey: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Present but neither `true` nor `false`. */
|
|
31
|
+
| {
|
|
32
|
+
kind: 'invalid';
|
|
33
|
+
detail: string;
|
|
34
|
+
}
|
|
35
|
+
/** The frontmatter does not parse, so no field on it is read at all. */
|
|
36
|
+
| {
|
|
37
|
+
kind: 'unparseable';
|
|
38
|
+
detail: string;
|
|
39
|
+
}
|
|
40
|
+
/** `lat:` holds something that cannot carry a mapping key. */
|
|
41
|
+
| {
|
|
42
|
+
kind: 'unsupported';
|
|
43
|
+
detail: string;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Decide what turning the gate on in this document would do, without doing it.
|
|
47
|
+
*
|
|
48
|
+
* Parsed and re-emitted through the YAML document API rather than edited line
|
|
49
|
+
* by line. Line surgery looked cheaper — it preserves the file byte for byte
|
|
50
|
+
* except where it inserts — but it has to answer, from raw text, every question
|
|
51
|
+
* the parser already answers: which line opens the block, which lines are the
|
|
52
|
+
* `lat:` mapping's own children rather than a later key's, how far they are
|
|
53
|
+
* indented, and whether the value on the `lat:` line is a flow mapping, an
|
|
54
|
+
* anchor or a block scalar. Each wrong answer was its own defect: a flow
|
|
55
|
+
* mapping and an anchor were refused although both merge cleanly, an empty
|
|
56
|
+
* `lat:` beside a sibling list was refused because the scan walked past the end
|
|
57
|
+
* of the block, and one shape produced frontmatter that no longer parsed at
|
|
58
|
+
* all. `parseDocument` answers all of it, and round-trips comments and blank
|
|
59
|
+
* lines while doing so.
|
|
60
|
+
*
|
|
61
|
+
* The one thing the document API does not give for free is that the fields
|
|
62
|
+
* already there survive, so that is asserted below rather than assumed.
|
|
63
|
+
*/
|
|
64
|
+
export declare function planRequireMode(content: string): StampPlan;
|
|
65
|
+
/** The edited document when the gate can be turned on, the input when not. */
|
|
2
66
|
export declare function stampRequireMode(content: string): string;
|
|
3
67
|
/**
|
|
4
68
|
* Append the four mode directories to the root index.
|
|
5
69
|
*
|
|
6
70
|
* Without this a fresh `lat init` leaves `lat check index` reporting four
|
|
7
71
|
* missing entries, which is a poor first impression of a tool whose whole
|
|
8
|
-
* pitch is that the check passes.
|
|
72
|
+
* pitch is that the check passes. Each directory is tested for separately: an
|
|
73
|
+
* index that already links `reference/` used to suppress the other three, so
|
|
74
|
+
* the three unlinked directories this had just created turned a passing
|
|
75
|
+
* `lat check index` into a failing one.
|
|
9
76
|
*/
|
|
10
77
|
export declare function listModeDirs(content: string): string;
|
|
11
78
|
/**
|
|
12
|
-
* Scaffold the four mode directories and turn the gate on.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
79
|
+
* Scaffold the four mode directories and turn the gate on.
|
|
80
|
+
*
|
|
81
|
+
* Nothing at all happens when the gate cannot land in the root index. A tree
|
|
82
|
+
* left with four new directories and the gate still off is worse than one left
|
|
83
|
+
* alone: `checkMode` passes on it, so nothing ever says the adoption stopped
|
|
84
|
+
* half way — and if the index was not rewritten to list them, `lat check index`
|
|
85
|
+
* fails instead, on four directories the user never asked for.
|
|
15
86
|
*/
|
|
16
87
|
export declare function writeForkScaffold(latDir: string): void;
|
|
88
|
+
/**
|
|
89
|
+
* Offer the gate to a tree `lat init` did not create.
|
|
90
|
+
*
|
|
91
|
+
* `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
|
|
92
|
+
* left the flag unreachable for every project that already had one — that is,
|
|
93
|
+
* for every project that needs it. Restructuring someone's tree without asking
|
|
94
|
+
* is still wrong, so this asks.
|
|
95
|
+
*
|
|
96
|
+
* Whether the flag *can* be written is decided before anything is asked and
|
|
97
|
+
* before anything is written. Asking first cost a tree four directories and a
|
|
98
|
+
* rewritten index in exchange for a gate that never landed, and the user was
|
|
99
|
+
* told in the same breath that nothing could be edited.
|
|
100
|
+
*
|
|
101
|
+
* Silent when the flag is already set either way, when a previous run was told
|
|
102
|
+
* no, and when there is no TTY to ask — the non-interactive path prints the
|
|
103
|
+
* count and the manual edit instead, and records nothing, so a later
|
|
104
|
+
* interactive run still offers.
|
|
105
|
+
*/
|
|
106
|
+
export declare function offerRequireMode(latDir: string, interactive: boolean, ask: (message: string) => Promise<boolean>): Promise<void>;
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { basename, join } from 'node:path';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import { styleText } from 'node:util';
|
|
4
|
-
import {
|
|
4
|
+
import { isMap, isScalar, isSeq, parseDocument } from 'yaml';
|
|
5
|
+
import { parseFrontmatter } from '../lattice.js';
|
|
6
|
+
import { DIATAXIS_MODES, MODE_DIRS, checkMode, indexNameFor, } from './check-mode.js';
|
|
5
7
|
/**
|
|
6
8
|
* Give a freshly created `lat.md/` the Diátaxis shape, and turn the gate on.
|
|
7
9
|
*
|
|
@@ -70,38 +72,213 @@ function indexBody(dir) {
|
|
|
70
72
|
'from a decision belong in `how-to/`, and the facts it fixes belong in ' +
|
|
71
73
|
'`reference/`.\n');
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
// ── Turning the gate on in a root index ──────────────────────────────
|
|
76
|
+
/**
|
|
77
|
+
* The frontmatter fence, copied from `parseFrontmatter` rather than written
|
|
78
|
+
* afresh.
|
|
79
|
+
*
|
|
80
|
+
* The two readers have to agree on what counts as frontmatter. When they did
|
|
81
|
+
* not — this pattern once required the closing fence to end its line, which
|
|
82
|
+
* upstream's does not — a root index closed with `----` was frontmatter to the
|
|
83
|
+
* parser and no frontmatter here, so a whole second block was prepended above
|
|
84
|
+
* the real one. The original block still sat in the file, still looked live,
|
|
85
|
+
* and every field it declared was silently no longer read.
|
|
86
|
+
*/
|
|
87
|
+
const FENCE = /^---\n([\s\S]*?)\n---/;
|
|
88
|
+
/**
|
|
89
|
+
* `lineWidth: 0` stops long values being wrapped onto continuation lines, and
|
|
90
|
+
* `flowCollectionPadding: false` keeps `[a, b]` from becoming `[ a, b ]`. Both
|
|
91
|
+
* exist to keep the diff to the line that changed.
|
|
92
|
+
*/
|
|
93
|
+
const STRINGIFY = { flowCollectionPadding: false, lineWidth: 0 };
|
|
94
|
+
/**
|
|
95
|
+
* Read the gate straight from a root index's frontmatter.
|
|
96
|
+
*
|
|
97
|
+
* Four states rather than a boolean, because `checkMode` enforces on `=== true`
|
|
98
|
+
* and anything looser here disagrees with it. A root index reading
|
|
99
|
+
* `require-mode: yes` is a string to this parser: treating that as "set" left
|
|
100
|
+
* the user believing the gate was on, `checkMode` enforcing nothing, and the
|
|
101
|
+
* one prompt that would have said so permanently silenced by the same value.
|
|
102
|
+
* `invalid` is reported rather than assumed either way.
|
|
103
|
+
*/
|
|
104
|
+
export function requireModeState(content) {
|
|
105
|
+
const value = parseFrontmatter(content).raw['require-mode'];
|
|
106
|
+
if (value === undefined)
|
|
107
|
+
return 'unset';
|
|
108
|
+
if (value === true)
|
|
109
|
+
return 'on';
|
|
110
|
+
if (value === false)
|
|
111
|
+
return 'off';
|
|
112
|
+
return 'invalid';
|
|
113
|
+
}
|
|
114
|
+
function shapeOf(node) {
|
|
115
|
+
if (isSeq(node))
|
|
116
|
+
return 'a list';
|
|
117
|
+
if (isScalar(node))
|
|
118
|
+
return `the value ${JSON.stringify(node.value)}`;
|
|
119
|
+
return 'something other than a mapping';
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Decide what turning the gate on in this document would do, without doing it.
|
|
123
|
+
*
|
|
124
|
+
* Parsed and re-emitted through the YAML document API rather than edited line
|
|
125
|
+
* by line. Line surgery looked cheaper — it preserves the file byte for byte
|
|
126
|
+
* except where it inserts — but it has to answer, from raw text, every question
|
|
127
|
+
* the parser already answers: which line opens the block, which lines are the
|
|
128
|
+
* `lat:` mapping's own children rather than a later key's, how far they are
|
|
129
|
+
* indented, and whether the value on the `lat:` line is a flow mapping, an
|
|
130
|
+
* anchor or a block scalar. Each wrong answer was its own defect: a flow
|
|
131
|
+
* mapping and an anchor were refused although both merge cleanly, an empty
|
|
132
|
+
* `lat:` beside a sibling list was refused because the scan walked past the end
|
|
133
|
+
* of the block, and one shape produced frontmatter that no longer parsed at
|
|
134
|
+
* all. `parseDocument` answers all of it, and round-trips comments and blank
|
|
135
|
+
* lines while doing so.
|
|
136
|
+
*
|
|
137
|
+
* The one thing the document API does not give for free is that the fields
|
|
138
|
+
* already there survive, so that is asserted below rather than assumed.
|
|
139
|
+
*/
|
|
140
|
+
export function planRequireMode(content) {
|
|
141
|
+
const state = requireModeState(content);
|
|
142
|
+
if (state === 'on')
|
|
143
|
+
return { kind: 'on' };
|
|
144
|
+
if (state === 'off')
|
|
145
|
+
return { kind: 'off' };
|
|
146
|
+
if (state === 'invalid') {
|
|
147
|
+
const value = parseFrontmatter(content).raw['require-mode'];
|
|
148
|
+
return {
|
|
149
|
+
kind: 'invalid',
|
|
150
|
+
detail: `require-mode is ${JSON.stringify(value)}; it must be true or false`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const fence = content.match(FENCE);
|
|
154
|
+
// Every branch below produces `stamped` and then falls through to the same
|
|
155
|
+
// gate. Returning early from any of them is how the last version let the
|
|
156
|
+
// "no frontmatter here" branch write a second block above frontmatter the
|
|
157
|
+
// parser was reading perfectly well.
|
|
158
|
+
let stamped;
|
|
159
|
+
let movedRootKey = false;
|
|
160
|
+
if (!fence) {
|
|
161
|
+
stamped = `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const doc = parseDocument(fence[1]);
|
|
165
|
+
if (doc.errors.length > 0) {
|
|
166
|
+
return {
|
|
167
|
+
kind: 'unparseable',
|
|
168
|
+
detail: doc.errors[0].message.split('\n')[0],
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const node = doc.get('lat', true);
|
|
172
|
+
if (node === undefined || node === null) {
|
|
173
|
+
doc.set('lat', doc.createNode({ 'require-mode': true }));
|
|
174
|
+
}
|
|
175
|
+
else if (isScalar(node) && node.value === null) {
|
|
176
|
+
// `lat:` with nothing under it. Replacing the key in place keeps its
|
|
177
|
+
// position and any comment attached to it.
|
|
178
|
+
doc.set('lat', doc.createNode({ 'require-mode': true }));
|
|
179
|
+
}
|
|
180
|
+
else if (isMap(node)) {
|
|
181
|
+
doc.setIn(['lat', 'require-mode'], true);
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return {
|
|
185
|
+
kind: 'unsupported',
|
|
186
|
+
detail: `\`lat:\` holds ${shapeOf(node)}, which cannot take \`require-mode\``,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// A root-level `require-mode` is dead — upstream reads fields only under
|
|
190
|
+
// `lat:` — and `checkFrontmatter` reports it as misplaced. Once the nested
|
|
191
|
+
// key exists that report stops firing, because the diagnostic asks whether
|
|
192
|
+
// the field is missing from `lat:`. Leaving the dead key behind would mean
|
|
193
|
+
// silently ending the one message that would ever have mentioned it, so it
|
|
194
|
+
// is moved rather than shadowed.
|
|
195
|
+
movedRootKey = doc.has('require-mode');
|
|
196
|
+
if (movedRootKey)
|
|
197
|
+
doc.delete('require-mode');
|
|
198
|
+
stamped = `---\n${doc.toString(STRINGIFY)}---${content.slice(fence[0].length)}`;
|
|
199
|
+
}
|
|
200
|
+
// The invariant, asserted rather than argued.
|
|
201
|
+
//
|
|
202
|
+
// Setting the flag is worth nothing if the merge quietly drops a field that
|
|
203
|
+
// was already there: an edit that lands `require-mode: true` and loses
|
|
204
|
+
// `require-code-mention: true` turns one check on and another off, and `lat
|
|
205
|
+
// check` then passes on a document that used to be enforced. Checking that
|
|
206
|
+
// the output parses is not enough to catch it — the losing edit parses fine.
|
|
207
|
+
// So every key read before must read back the same afterwards.
|
|
208
|
+
const before = parseFrontmatter(content);
|
|
209
|
+
const after = parseFrontmatter(stamped);
|
|
210
|
+
if ((after.problems ?? []).some((problem) => problem.kind === 'parse-error')) {
|
|
211
|
+
return {
|
|
212
|
+
kind: 'unsupported',
|
|
213
|
+
detail: 'the merged frontmatter did not parse',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (after.raw['require-mode'] !== true) {
|
|
217
|
+
return {
|
|
218
|
+
kind: 'unsupported',
|
|
219
|
+
detail: 'the flag did not land where it is read',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
for (const [key, value] of Object.entries(before.raw)) {
|
|
223
|
+
if (JSON.stringify(after.raw[key]) !== JSON.stringify(value)) {
|
|
224
|
+
return {
|
|
225
|
+
kind: 'unsupported',
|
|
226
|
+
detail: `the merge would change \`${key}\``,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { kind: 'stamped', content: stamped, movedRootKey };
|
|
231
|
+
}
|
|
232
|
+
/** The edited document when the gate can be turned on, the input when not. */
|
|
74
233
|
export function stampRequireMode(content) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return `---\nlat:\n require-mode: true\n---\n\n${content.replace(/^\n+/, '')}`;
|
|
234
|
+
const plan = planRequireMode(content);
|
|
235
|
+
return plan.kind === 'stamped' ? plan.content : content;
|
|
78
236
|
}
|
|
79
237
|
/**
|
|
80
238
|
* Append the four mode directories to the root index.
|
|
81
239
|
*
|
|
82
240
|
* Without this a fresh `lat init` leaves `lat check index` reporting four
|
|
83
241
|
* missing entries, which is a poor first impression of a tool whose whole
|
|
84
|
-
* pitch is that the check passes.
|
|
242
|
+
* pitch is that the check passes. Each directory is tested for separately: an
|
|
243
|
+
* index that already links `reference/` used to suppress the other three, so
|
|
244
|
+
* the three unlinked directories this had just created turned a passing
|
|
245
|
+
* `lat check index` into a failing one.
|
|
85
246
|
*/
|
|
86
247
|
export function listModeDirs(content) {
|
|
87
|
-
const
|
|
88
|
-
|
|
248
|
+
const missing = DIATAXIS_MODES.map((mode) => MODE_DIRS[mode]).filter((dir) => !content.includes(`(${dir}/`));
|
|
249
|
+
if (missing.length === 0)
|
|
250
|
+
return content;
|
|
251
|
+
const entries = missing
|
|
252
|
+
.map((dir) => {
|
|
89
253
|
const { title, lead } = MODE_INDEX[dir];
|
|
90
254
|
return `- [${title}](${dir}/${dir}.md) — ${lead}`;
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
return content;
|
|
255
|
+
})
|
|
256
|
+
.join('\n');
|
|
94
257
|
let base = content;
|
|
95
258
|
if (!base.endsWith('\n'))
|
|
96
259
|
base += '\n';
|
|
97
|
-
|
|
260
|
+
const intro = missing.length === DIATAXIS_MODES.length
|
|
261
|
+
? '\nEvery document belongs in exactly one of these.\n\n'
|
|
262
|
+
: '\n';
|
|
263
|
+
return `${base}${intro}${entries}\n`;
|
|
98
264
|
}
|
|
99
265
|
/**
|
|
100
|
-
* Scaffold the four mode directories and turn the gate on.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
266
|
+
* Scaffold the four mode directories and turn the gate on.
|
|
267
|
+
*
|
|
268
|
+
* Nothing at all happens when the gate cannot land in the root index. A tree
|
|
269
|
+
* left with four new directories and the gate still off is worse than one left
|
|
270
|
+
* alone: `checkMode` passes on it, so nothing ever says the adoption stopped
|
|
271
|
+
* half way — and if the index was not rewritten to list them, `lat check index`
|
|
272
|
+
* fails instead, on four directories the user never asked for.
|
|
103
273
|
*/
|
|
104
274
|
export function writeForkScaffold(latDir) {
|
|
275
|
+
const rootIndex = join(latDir, indexNameFor(basename(latDir)));
|
|
276
|
+
const current = existsSync(rootIndex)
|
|
277
|
+
? readFileSync(rootIndex, 'utf-8')
|
|
278
|
+
: null;
|
|
279
|
+
const stamped = current === null ? null : stampRequireMode(current);
|
|
280
|
+
if (stamped !== null && requireModeState(stamped) !== 'on')
|
|
281
|
+
return;
|
|
105
282
|
const created = [];
|
|
106
283
|
for (const mode of DIATAXIS_MODES) {
|
|
107
284
|
const dir = MODE_DIRS[mode];
|
|
@@ -113,12 +290,10 @@ export function writeForkScaffold(latDir) {
|
|
|
113
290
|
writeFileSync(indexPath, indexBody(dir));
|
|
114
291
|
created.push(`${dir}/`);
|
|
115
292
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (stamped !== current)
|
|
121
|
-
writeFileSync(rootIndex, stamped);
|
|
293
|
+
if (current !== null && stamped !== null) {
|
|
294
|
+
const listed = listModeDirs(stamped);
|
|
295
|
+
if (listed !== current)
|
|
296
|
+
writeFileSync(rootIndex, listed);
|
|
122
297
|
}
|
|
123
298
|
if (created.length > 0) {
|
|
124
299
|
console.log(styleText('green', 'Scaffolded Diátaxis modes') +
|
|
@@ -127,3 +302,189 @@ export function writeForkScaffold(latDir) {
|
|
|
127
302
|
styleText('dim', ' (every document belongs in one of these)'));
|
|
128
303
|
}
|
|
129
304
|
}
|
|
305
|
+
// ── Adopting the gate in a tree that already exists ──────────────────
|
|
306
|
+
/**
|
|
307
|
+
* Where a declined offer is remembered.
|
|
308
|
+
*
|
|
309
|
+
* Fork-owned rather than a field on upstream's `lat_init.json`, so recording
|
|
310
|
+
* it costs no edit to `src/init-version.ts`.
|
|
311
|
+
*/
|
|
312
|
+
function markerPath(latDir) {
|
|
313
|
+
return join(latDir, '.cache', 'lat_fork.json');
|
|
314
|
+
}
|
|
315
|
+
function readForkMeta(latDir) {
|
|
316
|
+
try {
|
|
317
|
+
const raw = JSON.parse(readFileSync(markerPath(latDir), 'utf-8'));
|
|
318
|
+
// An array is an object too, and spreading one produces `{"0": ...}` —
|
|
319
|
+
// which then overwrites the file with something no reader understands.
|
|
320
|
+
if (typeof raw === 'object' && raw !== null && !Array.isArray(raw)) {
|
|
321
|
+
return raw;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// Absent or unreadable — nothing has been settled yet.
|
|
326
|
+
}
|
|
327
|
+
return {};
|
|
328
|
+
}
|
|
329
|
+
function declined(latDir) {
|
|
330
|
+
return readForkMeta(latDir).require_mode_declined === true;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Remember a no.
|
|
334
|
+
*
|
|
335
|
+
* The marker is a local answer, not a project fact: committed, it would tell
|
|
336
|
+
* every other clone that this project had already declined. `lat.md/.gitignore`
|
|
337
|
+
* covers `.cache/`, but that file is written only by the run that *creates*
|
|
338
|
+
* `lat.md/` — and this path exists for trees `lat init` did not create — so the
|
|
339
|
+
* cache is made to ignore itself rather than trusting a file that may not be
|
|
340
|
+
* there.
|
|
341
|
+
*
|
|
342
|
+
* Failure to record is reported and survived. This runs after the tree has
|
|
343
|
+
* already been restructured, and an unwritable cache directory is not a reason
|
|
344
|
+
* to abort `lat init` with a stack trace at that point.
|
|
345
|
+
*/
|
|
346
|
+
function recordDeclined(latDir) {
|
|
347
|
+
try {
|
|
348
|
+
const cacheDir = join(latDir, '.cache');
|
|
349
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
350
|
+
const selfIgnore = join(cacheDir, '.gitignore');
|
|
351
|
+
if (!existsSync(selfIgnore))
|
|
352
|
+
writeFileSync(selfIgnore, '*\n');
|
|
353
|
+
writeFileSync(markerPath(latDir), JSON.stringify({ ...readForkMeta(latDir), require_mode_declined: true }, null, 2) + '\n');
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
console.log(' ' +
|
|
357
|
+
styleText('dim', `Could not record the answer (${err.message}) — the offer will repeat.`));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* What adopting the gate would newly cost, measured with the gate itself.
|
|
362
|
+
*
|
|
363
|
+
* Counted by running `checkMode` twice and taking the difference rather than
|
|
364
|
+
* by re-implementing its classification here. The hand-written version drifted
|
|
365
|
+
* immediately: it skipped every document inside a mode directory before
|
|
366
|
+
* reading what that document declared, so two files with byte-identical
|
|
367
|
+
* frontmatter were counted or not according to where they sat, and it reported
|
|
368
|
+
* one offender against two errors. Advertising a document as settled and then
|
|
369
|
+
* failing it is the failure worth avoiding, and only the checker knows.
|
|
370
|
+
*/
|
|
371
|
+
async function adoptionCost(latDir) {
|
|
372
|
+
const projectRoot = dirname(latDir);
|
|
373
|
+
const key = (error) => `${error.file}:${error.line}:${error.message}`;
|
|
374
|
+
const [gated, ungated] = await Promise.all([
|
|
375
|
+
checkMode(latDir, projectRoot, { requireMode: true }),
|
|
376
|
+
checkMode(latDir, projectRoot, { requireMode: false }),
|
|
377
|
+
]);
|
|
378
|
+
const already = new Set(ungated.map(key));
|
|
379
|
+
return [
|
|
380
|
+
...new Set(gated.filter((e) => !already.has(key(e))).map((e) => e.target)),
|
|
381
|
+
];
|
|
382
|
+
}
|
|
383
|
+
/** Say what is wrong with a root index the gate cannot be written into. */
|
|
384
|
+
function reportUnstampable(latDir, plan) {
|
|
385
|
+
const where = `${basename(latDir)}/${indexNameFor(basename(latDir))}`;
|
|
386
|
+
console.log('');
|
|
387
|
+
console.log(styleText('bold', 'Diátaxis modes'));
|
|
388
|
+
console.log(' ' +
|
|
389
|
+
styleText('dim', `require-mode cannot be set in ${where}: `) +
|
|
390
|
+
('detail' in plan ? plan.detail : ''));
|
|
391
|
+
if (plan.kind === 'unparseable') {
|
|
392
|
+
// Worth more than the offer that prompted it. Every `lat:` field on a
|
|
393
|
+
// document whose frontmatter does not parse is silently ignored, so this
|
|
394
|
+
// root index is already not being read.
|
|
395
|
+
console.log(' ' +
|
|
396
|
+
styleText('dim', 'Until that parses, no `lat:` field on this document is read at all.'));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Offer the gate to a tree `lat init` did not create.
|
|
401
|
+
*
|
|
402
|
+
* `writeForkScaffold` runs only on the branch that creates `lat.md/`, which
|
|
403
|
+
* left the flag unreachable for every project that already had one — that is,
|
|
404
|
+
* for every project that needs it. Restructuring someone's tree without asking
|
|
405
|
+
* is still wrong, so this asks.
|
|
406
|
+
*
|
|
407
|
+
* Whether the flag *can* be written is decided before anything is asked and
|
|
408
|
+
* before anything is written. Asking first cost a tree four directories and a
|
|
409
|
+
* rewritten index in exchange for a gate that never landed, and the user was
|
|
410
|
+
* told in the same breath that nothing could be edited.
|
|
411
|
+
*
|
|
412
|
+
* Silent when the flag is already set either way, when a previous run was told
|
|
413
|
+
* no, and when there is no TTY to ask — the non-interactive path prints the
|
|
414
|
+
* count and the manual edit instead, and records nothing, so a later
|
|
415
|
+
* interactive run still offers.
|
|
416
|
+
*/
|
|
417
|
+
export async function offerRequireMode(latDir, interactive, ask) {
|
|
418
|
+
const rootIndex = join(latDir, indexNameFor(basename(latDir)));
|
|
419
|
+
if (!existsSync(rootIndex))
|
|
420
|
+
return;
|
|
421
|
+
let current;
|
|
422
|
+
try {
|
|
423
|
+
current = readFileSync(rootIndex, 'utf-8');
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
const plan = planRequireMode(current);
|
|
429
|
+
if (plan.kind === 'on' || plan.kind === 'off')
|
|
430
|
+
return;
|
|
431
|
+
if (plan.kind !== 'stamped') {
|
|
432
|
+
// Not recorded and not silenced: this is a defect in the document with a
|
|
433
|
+
// fix the user can apply, and it stops being printed the moment they do.
|
|
434
|
+
reportUnstampable(latDir, plan);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (declined(latDir))
|
|
438
|
+
return;
|
|
439
|
+
const offenders = await adoptionCost(latDir);
|
|
440
|
+
console.log('');
|
|
441
|
+
console.log(styleText('bold', 'Diátaxis modes'));
|
|
442
|
+
console.log(' ' +
|
|
443
|
+
styleText('dim', 'Every document belongs in one of four modes. Without') +
|
|
444
|
+
' require-mode ' +
|
|
445
|
+
styleText('dim', 'a document'));
|
|
446
|
+
console.log(' ' +
|
|
447
|
+
styleText('dim', 'placed outside a mode directory is checked against no shape rule at all.'));
|
|
448
|
+
if (offenders.length > 0) {
|
|
449
|
+
console.log('');
|
|
450
|
+
console.log(` ${styleText('yellow', String(offenders.length))} document(s) would need a mode:`);
|
|
451
|
+
for (const name of offenders.slice(0, 5)) {
|
|
452
|
+
console.log(' ' + styleText('dim', name));
|
|
453
|
+
}
|
|
454
|
+
if (offenders.length > 5) {
|
|
455
|
+
console.log(' ' + styleText('dim', `... and ${offenders.length - 5} more`));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (!interactive) {
|
|
459
|
+
console.log('');
|
|
460
|
+
console.log(' ' +
|
|
461
|
+
styleText('dim', 'To turn it on, add this to the top of') +
|
|
462
|
+
` ${basename(latDir)}/${indexNameFor(basename(latDir))}:`);
|
|
463
|
+
console.log('');
|
|
464
|
+
for (const line of ['---', 'lat:', ' require-mode: true', '---']) {
|
|
465
|
+
console.log(' ' + styleText('cyan', line));
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
console.log('');
|
|
470
|
+
if (!(await ask(' Turn require-mode on for this project?'))) {
|
|
471
|
+
recordDeclined(latDir);
|
|
472
|
+
console.log(' ' +
|
|
473
|
+
styleText('dim', 'Skipped. Add') +
|
|
474
|
+
' require-mode: true ' +
|
|
475
|
+
styleText('dim', 'to the root index whenever you want it.'));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
writeFileSync(rootIndex, plan.content);
|
|
479
|
+
if (plan.movedRootKey) {
|
|
480
|
+
console.log(' ' +
|
|
481
|
+
styleText('dim', 'Moved the stray root-level require-mode under `lat:`, where it is read.'));
|
|
482
|
+
}
|
|
483
|
+
writeForkScaffold(latDir);
|
|
484
|
+
if (offenders.length > 0) {
|
|
485
|
+
console.log(' ' +
|
|
486
|
+
styleText('dim', 'Run') +
|
|
487
|
+
' lat check ' +
|
|
488
|
+
styleText('dim', 'to see which documents need placing.'));
|
|
489
|
+
}
|
|
490
|
+
}
|
package/dist/src/cli/init.js
CHANGED
|
@@ -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
|
+
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;
|
package/dist/src/init-version.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
3
|
+
"version": "0.12.3-fork.8",
|
|
4
4
|
"description": "A knowledge graph for your codebase, written in markdown",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
"templates"
|
|
28
28
|
],
|
|
29
29
|
"devDependencies": {
|
|
30
|
+
"@types/mdast": "^4.0.4",
|
|
31
|
+
"@types/node": "^25.3.0",
|
|
30
32
|
"@types/react": "^19.0.0",
|
|
31
33
|
"@types/react-dom": "^19.0.0",
|
|
32
34
|
"@vitejs/plugin-react": "^5.0.0",
|
|
33
|
-
"@types/mdast": "^4.0.4",
|
|
34
|
-
"@types/node": "^25.3.0",
|
|
35
35
|
"graphology": "^0.26.0",
|
|
36
36
|
"highlight.js": "^11.12.0",
|
|
37
37
|
"mdast-util-from-markdown": "^2.0.3",
|
|
@@ -54,13 +54,13 @@
|
|
|
54
54
|
"github-slugger": "^2.0.0",
|
|
55
55
|
"ignore-walk": "^8.0.0",
|
|
56
56
|
"mdast-util-to-markdown": "^2.1.0",
|
|
57
|
+
"rehype-sanitize": "^6.0.0",
|
|
58
|
+
"rehype-slug": "^6.0.0",
|
|
59
|
+
"rehype-stringify": "^10.0.1",
|
|
57
60
|
"remark-frontmatter": "^5.0.0",
|
|
58
61
|
"remark-parse": "^11.0.0",
|
|
59
62
|
"remark-rehype": "^11.1.2",
|
|
60
63
|
"remark-stringify": "^11.0.0",
|
|
61
|
-
"rehype-sanitize": "^6.0.0",
|
|
62
|
-
"rehype-slug": "^6.0.0",
|
|
63
|
-
"rehype-stringify": "^10.0.1",
|
|
64
64
|
"unified": "^11.0.0",
|
|
65
65
|
"unist-util-visit": "^5.0.0",
|
|
66
66
|
"web-tree-sitter": "^0.26.6",
|