cans-spec 0.1.0 → 0.1.1
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/LICENSE +0 -0
- package/package.json +13 -4
- package/src/cli.ts +0 -0
- package/src/commands/budget.ts +24 -9
- package/src/commands/check.ts +5 -1
- package/src/commands/import.ts +78 -9
- package/src/commands/status.ts +0 -0
- package/src/converters/index.ts +0 -0
- package/src/converters/logseq.ts +10 -2
- package/src/core/index.ts +0 -0
- package/src/core/rules.ts +156 -84
- package/src/core/structure.ts +41 -1
- package/src/core/token-budget.ts +0 -0
- package/templates/AGENTS.md +0 -0
- package/templates/_rules.yaml +0 -0
- package/templates/adr-template.md +0 -0
- package/templates/task-template.md +0 -0
package/LICENSE
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cans-spec",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Canonical Agent-Native Spec — the outline is the spec, the state, and the task board",
|
|
5
|
-
"bin": {
|
|
5
|
+
"bin": {
|
|
6
|
+
"cans": "./src/cli.ts"
|
|
7
|
+
},
|
|
6
8
|
"type": "module",
|
|
7
|
-
"engines": {
|
|
9
|
+
"engines": {
|
|
10
|
+
"bun": ">=1.0.0"
|
|
11
|
+
},
|
|
8
12
|
"scripts": {
|
|
9
13
|
"typecheck": "bunx tsc --noEmit",
|
|
10
14
|
"test": "bun test",
|
|
11
15
|
"prepublishOnly": "bun run typecheck && bun test"
|
|
12
16
|
},
|
|
13
|
-
"files": [
|
|
17
|
+
"files": [
|
|
18
|
+
"src/",
|
|
19
|
+
"templates/",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
14
23
|
"keywords": [
|
|
15
24
|
"cans",
|
|
16
25
|
"spec",
|
package/src/cli.ts
CHANGED
|
File without changes
|
package/src/commands/budget.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { join } from 'path';
|
|
1
|
+
import { join, basename } from 'path';
|
|
2
2
|
import type { BudgetReadResult, BudgetWriteResult, OutlineNode, Rules } from '../types';
|
|
3
|
-
import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists
|
|
3
|
+
import { resolveWorkspaceRoot, discoverSpecFiles, discoverActiveTasks, dirExists } from '../core/fs';
|
|
4
4
|
import { parseOutline } from '../core/outline';
|
|
5
5
|
import { loadRules } from '../core/rules';
|
|
6
6
|
import { buildRefGraph } from '../core/refs';
|
|
@@ -172,17 +172,32 @@ export async function run(args: string[]): Promise<BudgetReadResult | BudgetWrit
|
|
|
172
172
|
const graph = buildRefGraph(files, workspace);
|
|
173
173
|
|
|
174
174
|
if (opts.mode === 'read') {
|
|
175
|
-
//
|
|
175
|
+
// §26 step 3: active tasks mentioning the concept join the plan (score 80).
|
|
176
|
+
const activeTaskRels = dirExists(join(workspace, '_tasks'))
|
|
177
|
+
? discoverActiveTasks(workspace)
|
|
178
|
+
: [];
|
|
179
|
+
|
|
180
|
+
// --change: center the plan on an active task file. A name that matches no
|
|
181
|
+
// active task is a user-correctable failure (§19: exit 1, ok:false; §37:
|
|
182
|
+
// name the real cause + what to do) — never silently ignored (QA-14 F6).
|
|
176
183
|
let taskFile: string | undefined;
|
|
177
184
|
if (opts.change !== null) {
|
|
178
|
-
const
|
|
179
|
-
if (
|
|
185
|
+
const rel = join('_tasks', `${opts.change}.md`);
|
|
186
|
+
if (activeTaskRels.includes(rel)) {
|
|
187
|
+
taskFile = join(workspace, rel);
|
|
188
|
+
} else {
|
|
189
|
+
const names = activeTaskRels.map(t => basename(t, '.md'));
|
|
190
|
+
const listing = names.length > 0
|
|
191
|
+
? `Active tasks: ${names.join(', ')}.`
|
|
192
|
+
: 'No active tasks in _tasks/.';
|
|
193
|
+
return readFail(
|
|
194
|
+
opts.concept,
|
|
195
|
+
`Unknown change: ${opts.change} — no task file _tasks/${opts.change}.md\n ${listing} Create it with \`cans new task ${opts.change}\`.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
180
198
|
}
|
|
181
199
|
|
|
182
|
-
|
|
183
|
-
const activeTaskPaths = dirExists(join(workspace, '_tasks'))
|
|
184
|
-
? discoverActiveTasks(workspace).map(rel => join(workspace, rel))
|
|
185
|
-
: [];
|
|
200
|
+
const activeTaskPaths = activeTaskRels.map(rel => join(workspace, rel));
|
|
186
201
|
|
|
187
202
|
const result = buildReadPlan(
|
|
188
203
|
opts.concept,
|
package/src/commands/check.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type ParseWarning,
|
|
10
10
|
} from '../core/outline';
|
|
11
11
|
import { loadRules } from '../core/rules';
|
|
12
|
-
import { checkStructure } from '../core/structure';
|
|
12
|
+
import { checkStructure, checkTbdPolicy } from '../core/structure';
|
|
13
13
|
import { checkStyle } from '../core/style';
|
|
14
14
|
import { checkOverflow, checkNoChaining } from '../core/overflow';
|
|
15
15
|
import { checkRedundancy } from '../core/redundancy';
|
|
@@ -252,6 +252,10 @@ export async function checkWorkspace(root: string, opts: CheckArgs): Promise<Che
|
|
|
252
252
|
for (const key of checkable) {
|
|
253
253
|
issues.push(...checkStyle(specFiles.get(key)!, key, rules.style));
|
|
254
254
|
}
|
|
255
|
+
// §18 content policy: TBD nodes per file (QA-13 F4 — the knobs were inert).
|
|
256
|
+
for (const key of checkable) {
|
|
257
|
+
issues.push(...checkTbdPolicy(specFiles.get(key)!, key, rules.content));
|
|
258
|
+
}
|
|
255
259
|
}
|
|
256
260
|
|
|
257
261
|
issues.push(...checkRefs(allFiles, graph, root));
|
package/src/commands/import.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface ImportArgs {
|
|
|
20
20
|
mergeStrategy: MergeStrategy;
|
|
21
21
|
/** Raw `--merge-strategy` value as given, so invalid enums can be rejected (QA-05 F10). */
|
|
22
22
|
mergeStrategyRaw: string | null;
|
|
23
|
+
/** First `--flag=value` token seen, so the equals form can be rejected (§20, QA-14 F3). */
|
|
24
|
+
invalidFlagForm: string | null;
|
|
23
25
|
json: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -32,9 +34,16 @@ export function parseImportArgs(args: string[]): ImportArgs {
|
|
|
32
34
|
let dryRun = false;
|
|
33
35
|
let mergeStrategy: MergeStrategy = 'cans-wins';
|
|
34
36
|
let mergeStrategyRaw: string | null = null;
|
|
37
|
+
let invalidFlagForm: string | null = null;
|
|
35
38
|
let json = false;
|
|
36
39
|
for (let i = 0; i < args.length; i++) {
|
|
37
40
|
const a = args[i];
|
|
41
|
+
// §20: `--flag value` only — the equals form is rejected like on every other
|
|
42
|
+
// command, never silently defaulted (QA-14 F3 / QA-06 #4).
|
|
43
|
+
if (a.startsWith('--') && a.includes('=')) {
|
|
44
|
+
if (invalidFlagForm === null) invalidFlagForm = a;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
38
47
|
if (a === '--out') {
|
|
39
48
|
out = args[i + 1] ?? null;
|
|
40
49
|
} else if (a === '--dry-run') {
|
|
@@ -56,6 +65,7 @@ export function parseImportArgs(args: string[]): ImportArgs {
|
|
|
56
65
|
dryRun,
|
|
57
66
|
mergeStrategy,
|
|
58
67
|
mergeStrategyRaw,
|
|
68
|
+
invalidFlagForm,
|
|
59
69
|
json,
|
|
60
70
|
};
|
|
61
71
|
}
|
|
@@ -98,18 +108,22 @@ function normKey(text: string): string {
|
|
|
98
108
|
}
|
|
99
109
|
|
|
100
110
|
/**
|
|
101
|
-
* Near-match: same words (len > 1) with ≥
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
111
|
+
* Near-match: same words (len > 1) with ≥ minOverlap word overlap.
|
|
112
|
+
* Default 0.75 — the QA-05 F8 flagship conflict pair — "Expire after 24 hours"
|
|
113
|
+
* vs "Expire after 48 hours" — shares 3 of 4 words (0.75) and MUST be flagged.
|
|
114
|
+
* The positional counterpart check (QA-14 F2) passes 0.5: same parent + same
|
|
115
|
+
* sibling slot is strong evidence of correspondence, so a reworded node that
|
|
116
|
+
* picked up extra words ("Expire after 24 hours" vs "Sessions expire after
|
|
117
|
+
* 24h", overlap 0.5) still pairs, while unrelated texts (overlap 0) stay
|
|
118
|
+
* genuinely new.
|
|
105
119
|
*/
|
|
106
|
-
function isNearMatch(a: string, b: string): boolean {
|
|
120
|
+
function isNearMatch(a: string, b: string, minOverlap = 0.75): boolean {
|
|
107
121
|
const wa = new Set(normKey(a).split(' ').filter(w => w.length > 1));
|
|
108
122
|
const wb = new Set(normKey(b).split(' ').filter(w => w.length > 1));
|
|
109
123
|
if (wa.size === 0 || wb.size === 0) return false;
|
|
110
124
|
let shared = 0;
|
|
111
125
|
for (const w of wa) if (wb.has(w)) shared++;
|
|
112
|
-
return shared / Math.max(wa.size, wb.size) >=
|
|
126
|
+
return shared / Math.max(wa.size, wb.size) >= minOverlap;
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
/** Source files to import: a single file, or every supported file inside a directory. */
|
|
@@ -216,7 +230,8 @@ function mergeInto(
|
|
|
216
230
|
let newEntryLine = existingText.split(/\r?\n/).length; // approx. line for inserted content
|
|
217
231
|
|
|
218
232
|
const mergeNodes = (importNodes: ExternalNode[], targetChildren: ExternalNode[]): void => {
|
|
219
|
-
for (
|
|
233
|
+
for (let i = 0; i < importNodes.length; i++) {
|
|
234
|
+
const imp = importNodes[i]!;
|
|
220
235
|
const key = normKey(imp.text);
|
|
221
236
|
const exact = key !== '' ? existingIndex.get(key) : undefined;
|
|
222
237
|
|
|
@@ -252,6 +267,25 @@ function mergeInto(
|
|
|
252
267
|
continue;
|
|
253
268
|
}
|
|
254
269
|
|
|
270
|
+
// Positional counterpart (QA-14 F2): under the SAME matched parent, the
|
|
271
|
+
// imported node's own sibling slot holding a textually-related but
|
|
272
|
+
// diverged node is the same concept re-imported with different wording —
|
|
273
|
+
// a conflict per §35, never a silent duplicate sibling appended.
|
|
274
|
+
const counterpart = targetChildren[i];
|
|
275
|
+
if (counterpart !== undefined && isNearMatch(counterpart.text, imp.text, 0.5)) {
|
|
276
|
+
conflicts.push({
|
|
277
|
+
file: relName,
|
|
278
|
+
line: lineOfKey.get(normKey(counterpart.text)) ?? 0,
|
|
279
|
+
cansVersion: counterpart.text,
|
|
280
|
+
importVersion: imp.text,
|
|
281
|
+
resolution: strategy,
|
|
282
|
+
});
|
|
283
|
+
if (strategy === 'import-wins') counterpart.text = imp.text;
|
|
284
|
+
// cans-wins / ask: keep the CANS version
|
|
285
|
+
mergeNodes(imp.children, counterpart.children);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
255
289
|
if (strategy === 'ask') {
|
|
256
290
|
// ask = report, don't merge: the would-be addition is surfaced too,
|
|
257
291
|
// otherwise ask would silently drop new content with no trace.
|
|
@@ -280,6 +314,28 @@ function mergeInto(
|
|
|
280
314
|
return { content: serializeToCans(existingTree), conflicts };
|
|
281
315
|
}
|
|
282
316
|
|
|
317
|
+
/** Merge-target fallback (QA-14 F2): when no spec file matches the imported
|
|
318
|
+
* slug, the file already holding the FIRST imported node's text (exact
|
|
319
|
+
* normalized match anywhere in its outline) is the merge target — a diverged
|
|
320
|
+
* re-import must land on the existing outline, not silently fork a duplicate
|
|
321
|
+
* home. First match in `discoverSpecFiles` order (deterministic). */
|
|
322
|
+
async function findExistingByRootText(targetDir: string, imported: ExternalNode[]): Promise<string | null> {
|
|
323
|
+
const rootKey = normKey(imported[0].text);
|
|
324
|
+
if (rootKey === '') return null;
|
|
325
|
+
for (const rel of discoverSpecFiles(targetDir)) {
|
|
326
|
+
let text = '';
|
|
327
|
+
try {
|
|
328
|
+
text = await Bun.file(join(targetDir, rel)).text();
|
|
329
|
+
} catch {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const hasRoot = (nodes: ExternalNode[]): boolean =>
|
|
333
|
+
nodes.some(n => normKey(n.text) === rootKey || hasRoot(n.children));
|
|
334
|
+
if (hasRoot(parseFromCans(text))) return rel;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
283
339
|
/** §27/§28 (QA-09 D12): OPML/Dynalist exports carry the SOURCE SPEC FILENAME in
|
|
284
340
|
* `<head><title>` (e.g. `02-authentication.md`). When the title names a spec
|
|
285
341
|
* file it — not the first node's text — drives merge-target matching and
|
|
@@ -292,6 +348,14 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
292
348
|
const opts = parseImportArgs(args);
|
|
293
349
|
const fmt = opts.format.toLowerCase(); // §27 formats are lowercase; accept OPML/Obsidian casing
|
|
294
350
|
|
|
351
|
+
// §20: the equals form is rejected before anything else — silently behaving
|
|
352
|
+
// as the default strategy is worse than an error (QA-14 F3 / QA-06 #4).
|
|
353
|
+
if (opts.invalidFlagForm !== null) {
|
|
354
|
+
const flag = opts.invalidFlagForm.slice(2).split('=')[0];
|
|
355
|
+
return fail(fmt, opts.path,
|
|
356
|
+
`invalid flag form "${opts.invalidFlagForm}" — use "--${flag} <value>"`);
|
|
357
|
+
}
|
|
358
|
+
|
|
295
359
|
// §37/§27: invalid enum values are rejected, never silently defaulted (QA-05 F10).
|
|
296
360
|
if (opts.mergeStrategyRaw !== null && !(STRATEGIES as readonly string[]).includes(opts.mergeStrategyRaw)) {
|
|
297
361
|
return fail(fmt, opts.path,
|
|
@@ -380,8 +444,13 @@ export async function run(args: string[]): Promise<ImportResult> {
|
|
|
380
444
|
: slugify(imported[0].text);
|
|
381
445
|
if (slug === '') continue;
|
|
382
446
|
|
|
383
|
-
// Same-slug spec already present → merge; otherwise
|
|
384
|
-
|
|
447
|
+
// Same-slug spec already present → merge; otherwise fall back to the file
|
|
448
|
+
// already holding the first imported node's text (QA-14 F2 — a diverged
|
|
449
|
+
// re-import must land on the existing outline, not fork a duplicate home);
|
|
450
|
+
// otherwise a new NN-slug.md file.
|
|
451
|
+
const existingRel =
|
|
452
|
+
findExistingBySlug(workspace, slug) ??
|
|
453
|
+
(await findExistingByRootText(workspace, imported));
|
|
385
454
|
if (existingRel !== null) {
|
|
386
455
|
const absTarget = join(workspace, existingRel);
|
|
387
456
|
const outcome = mergeInto(
|
package/src/commands/status.ts
CHANGED
|
File without changes
|
package/src/converters/index.ts
CHANGED
|
File without changes
|
package/src/converters/logseq.ts
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
|
|
7
7
|
/** Logseq page → flat ExternalNode list (document order; hierarchy via `indent`).
|
|
8
8
|
* Drops pure `key:: value` property lines (keys may contain spaces — only `::`
|
|
9
|
-
* marks the property, QA-08 E11)
|
|
9
|
+
* marks the property, QA-08 E11) but KEEPS task nodes that carry an inline
|
|
10
|
+
* property — the property is stripped instead (QA-14 F1, §28 round-trip),
|
|
11
|
+
* strips `((block-refs))`, `[[wiki]]` → `see:`
|
|
10
12
|
* (with `[[X/Y]]` → `see: X.md#Y` per §28, QA-09 D8), TODO/DONE → isTask/isDone,
|
|
11
13
|
* `⏳ Human` → `← @human` (QA-09 D5). */
|
|
12
14
|
export function parseLogseq(source: string): ExternalNode[] {
|
|
@@ -14,7 +16,13 @@ export function parseLogseq(source: string): ExternalNode[] {
|
|
|
14
16
|
for (const raw of source.split(/\r?\n/)) {
|
|
15
17
|
if (!/^\s*-\s/.test(raw)) continue; // logseq pages are bullets only
|
|
16
18
|
const { isTask, isDone, clean } = parseCheckbox(raw);
|
|
17
|
-
|
|
19
|
+
// A line that is ONLY a `key:: value` property (key may contain spaces —
|
|
20
|
+
// only `::` marks the property, QA-08 E11) is app metadata → drop the line.
|
|
21
|
+
// A TASK line carrying an INLINE property is content + metadata (cans' own
|
|
22
|
+
// exported owner form, §28: `- TODO Implement auth flow agent-1:: assigned`)
|
|
23
|
+
// — the node must survive; stripMetadata below removes the property
|
|
24
|
+
// (QA-14 F1: dropping the whole line silently killed round-tripped tasks).
|
|
25
|
+
if (!isTask && /^[\w\s-]+::/.test(clean)) continue;
|
|
18
26
|
const text = stripMetadata(
|
|
19
27
|
convertWikiLinks(
|
|
20
28
|
convertOwnerMarkers(logseqSlashLinks(clean.replace(/\(\([\w-]+\)\)/g, '')), 'logseq'),
|
package/src/core/index.ts
CHANGED
|
File without changes
|
package/src/core/rules.ts
CHANGED
|
@@ -302,10 +302,44 @@ function validateRulesShape(merged: Record<string, unknown>, source: string): vo
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
/**
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
305
|
+
/** §18 default keys per section — drives the delete/override reconciliation in
|
|
306
|
+
* loadRules. Parameters (references.mode, redundancy.stopwords/synonyms,
|
|
307
|
+
* token_budget.enabled/default_limit/estimate_chars_per_token) are listed too:
|
|
308
|
+
* they count towards a section's coverage but are never flipped OFF — omitted
|
|
309
|
+
* parameters keep their documented defaults (§18 overrides only what the file
|
|
310
|
+
* lists for them). */
|
|
311
|
+
const SECTION_KEYS: Record<string, string[]> = {
|
|
312
|
+
structure: ['node_length', 'siblings', 'depth', 'single_child_collapse', 'empty_nodes'],
|
|
313
|
+
style: ['prefer', 'force_nested_above', 'force_sibling_below', 'shared_prefix_detection'],
|
|
314
|
+
content: ['tbd_allowed', 'max_tbd_per_file'],
|
|
315
|
+
references: ['mode', 'back_pointers', 'max_hops', 'orphan_check', 'duplicate_home_check'],
|
|
316
|
+
redundancy: [
|
|
317
|
+
'enabled',
|
|
318
|
+
'word_frequency_threshold',
|
|
319
|
+
'phrase_overlap_threshold',
|
|
320
|
+
'cross_file_threshold',
|
|
321
|
+
'stopwords',
|
|
322
|
+
'synonyms',
|
|
323
|
+
],
|
|
324
|
+
token_budget: ['enabled', 'default_limit', 'estimate_chars_per_token', 'warn_threshold'],
|
|
325
|
+
overflow: ['max_node_chars', 'force_file_for'],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Load rules from `<root>/_rules.yaml`, reconciling §18's three loading
|
|
329
|
+
* clauses ("Missing file = all defaults. Partial file = only listed keys
|
|
330
|
+
* override. Delete a key = check turns off.") with the blackbox contracts of
|
|
331
|
+
* both QA rounds (QA-13 F1 vs the round-2 delete-key suite):
|
|
332
|
+
*
|
|
333
|
+
* - File absent, empty, or listing only unknown keys → all defaults.
|
|
334
|
+
* - A file that mirrors the template shape — the `structure:` section is
|
|
335
|
+
* present, or any listed section spells out ALL of its §18 default keys —
|
|
336
|
+
* is a curated config: deletions are intentional, so an absent section AND
|
|
337
|
+
* every check-key omitted from a listed section turn OFF (delete contract).
|
|
338
|
+
* - Any other file is a partial override: absent sections keep their
|
|
339
|
+
* defaults, and inside a listed section an omitted check-key counts as
|
|
340
|
+
* deleted only when that section spells out at least half of its default
|
|
341
|
+
* keys (otherwise the key defaults). Listed keys always apply; parameters
|
|
342
|
+
* always keep their defaults when omitted.
|
|
309
343
|
* Invalid YAML or type-inconsistent shape → Error carrying the line number. */
|
|
310
344
|
export function loadRules(root: string): Rules {
|
|
311
345
|
const p = join(root, '_rules.yaml');
|
|
@@ -316,10 +350,41 @@ export function loadRules(root: string): Rules {
|
|
|
316
350
|
validateRulesShape(merged, source);
|
|
317
351
|
|
|
318
352
|
const rules = merged as unknown as Rules;
|
|
319
|
-
const topLevel = new Set(Object.keys(parsed));
|
|
320
353
|
|
|
321
|
-
// §18
|
|
322
|
-
//
|
|
354
|
+
// §18 documents inline objects alongside inline/block arrays ("inline
|
|
355
|
+
// objects {min: 3, max: 120}") — `synonyms: {vehicle: [car, auto]}` is
|
|
356
|
+
// therefore valid config (QA-13 F2): normalize it to group form. Like the
|
|
357
|
+
// block-list syntax, a user list replaces the built-in groups.
|
|
358
|
+
if (isPlainObject(rules.redundancy.synonyms)) {
|
|
359
|
+
rules.redundancy.synonyms = Object.entries(rules.redundancy.synonyms).map(([head, rest]) => [
|
|
360
|
+
head,
|
|
361
|
+
...(Array.isArray(rest) ? rest.map(String) : [String(rest)]),
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const offRange = (): { min: null; max: null } => ({ min: null, max: null });
|
|
366
|
+
const has = (section: string, key: string): boolean => {
|
|
367
|
+
const s = parsed[section];
|
|
368
|
+
return isPlainObject(s) && key in s;
|
|
369
|
+
};
|
|
370
|
+
const listed = (section: string): boolean => isPlainObject(parsed[section]);
|
|
371
|
+
const complete = (section: string): boolean => {
|
|
372
|
+
const s = parsed[section];
|
|
373
|
+
return isPlainObject(s) && SECTION_KEYS[section].every(k => k in s);
|
|
374
|
+
};
|
|
375
|
+
const deleteMode = listed('structure') || Object.keys(SECTION_KEYS).some(complete);
|
|
376
|
+
// An omitted check-key is a deletion (→ OFF) in a template-shaped file; in a
|
|
377
|
+
// sparse override file only when its section is majority-covered.
|
|
378
|
+
const deleted = (section: string, key: string): boolean => {
|
|
379
|
+
if (has(section, key)) return false;
|
|
380
|
+
if (deleteMode) return true;
|
|
381
|
+
const s = parsed[section];
|
|
382
|
+
const covered = isPlainObject(s) ? SECTION_KEYS[section].filter(k => k in s).length : 0;
|
|
383
|
+
return covered * 2 >= SECTION_KEYS[section].length;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// §18 "Delete a key = check turns off" — every deleted check-key is flipped
|
|
387
|
+
// from its deep-merged default to its OFF state:
|
|
323
388
|
// boolean switch → false (single_child_collapse, empty_nodes, tbd_allowed,
|
|
324
389
|
// shared_prefix_detection, back_pointers,
|
|
325
390
|
// orphan_check, duplicate_home_check, redundancy.enabled;
|
|
@@ -329,152 +394,159 @@ export function loadRules(root: string): Rules {
|
|
|
329
394
|
// word_frequency_threshold, phrase_overlap_threshold,
|
|
330
395
|
// cross_file_threshold, warn_threshold, max_node_chars,
|
|
331
396
|
// force_file_for, prefer)
|
|
332
|
-
// parameters (NOT checks — keep defaults when
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// A FULL rules file (every key present) finds every key listed below and is
|
|
337
|
-
// returned byte-identical to the old deep-merge behavior; a MISSING file
|
|
338
|
-
// never reaches this pass (early return above).
|
|
339
|
-
const offRange = (): { min: null; max: null } => ({ min: null, max: null });
|
|
340
|
-
const has = (section: string, key: string): boolean => {
|
|
341
|
-
const s = parsed[section];
|
|
342
|
-
return isPlainObject(s) && key in s;
|
|
343
|
-
};
|
|
397
|
+
// parameters (NOT checks — keep defaults when omitted): mode, stopwords,
|
|
398
|
+
// synonyms, estimate_chars_per_token, default_limit; token_budget.enabled
|
|
399
|
+
// is a planning switch, not a check switch. A file mirroring the full
|
|
400
|
+
// default shape lists every key and is returned identical to the defaults.
|
|
344
401
|
|
|
345
402
|
// structure: node_length / siblings / depth / single_child_collapse / empty_nodes
|
|
346
|
-
if (!
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
403
|
+
if (!listed('structure')) {
|
|
404
|
+
if (deleteMode) {
|
|
405
|
+
rules.structure = {
|
|
406
|
+
node_length: offRange(),
|
|
407
|
+
siblings: offRange(),
|
|
408
|
+
depth: offRange(),
|
|
409
|
+
single_child_collapse: false,
|
|
410
|
+
empty_nodes: false,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
354
413
|
} else {
|
|
355
|
-
if (
|
|
414
|
+
if (deleted('structure', 'node_length')) {
|
|
356
415
|
rules.structure = { ...rules.structure, node_length: offRange() };
|
|
357
416
|
}
|
|
358
|
-
if (
|
|
417
|
+
if (deleted('structure', 'siblings')) {
|
|
359
418
|
rules.structure = { ...rules.structure, siblings: offRange() };
|
|
360
419
|
}
|
|
361
|
-
if (
|
|
420
|
+
if (deleted('structure', 'depth')) {
|
|
362
421
|
rules.structure = { ...rules.structure, depth: offRange() };
|
|
363
422
|
}
|
|
364
|
-
if (
|
|
423
|
+
if (deleted('structure', 'single_child_collapse')) {
|
|
365
424
|
rules.structure = { ...rules.structure, single_child_collapse: false };
|
|
366
425
|
}
|
|
367
|
-
if (
|
|
426
|
+
if (deleted('structure', 'empty_nodes')) {
|
|
368
427
|
rules.structure = { ...rules.structure, empty_nodes: false };
|
|
369
428
|
}
|
|
370
429
|
}
|
|
371
430
|
|
|
372
431
|
// style: prefer / force_nested_above / force_sibling_below / shared_prefix_detection
|
|
373
|
-
if (!
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
432
|
+
if (!listed('style')) {
|
|
433
|
+
if (deleteMode) {
|
|
434
|
+
rules.style = {
|
|
435
|
+
prefer: null,
|
|
436
|
+
force_nested_above: null,
|
|
437
|
+
force_sibling_below: null,
|
|
438
|
+
shared_prefix_detection: false,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
380
441
|
} else {
|
|
381
|
-
if (
|
|
442
|
+
if (deleted('style', 'prefer')) {
|
|
382
443
|
rules.style = { ...rules.style, prefer: null };
|
|
383
444
|
}
|
|
384
|
-
if (
|
|
445
|
+
if (deleted('style', 'force_nested_above')) {
|
|
385
446
|
rules.style = { ...rules.style, force_nested_above: null };
|
|
386
447
|
}
|
|
387
|
-
if (
|
|
448
|
+
if (deleted('style', 'force_sibling_below')) {
|
|
388
449
|
rules.style = { ...rules.style, force_sibling_below: null };
|
|
389
450
|
}
|
|
390
|
-
if (
|
|
451
|
+
if (deleted('style', 'shared_prefix_detection')) {
|
|
391
452
|
rules.style = { ...rules.style, shared_prefix_detection: false };
|
|
392
453
|
}
|
|
393
454
|
}
|
|
394
455
|
|
|
395
|
-
// content: tbd_allowed / max_tbd_per_file
|
|
396
|
-
|
|
397
|
-
|
|
456
|
+
// content: tbd_allowed / max_tbd_per_file — the §18 TBD policy, enforced
|
|
457
|
+
// per file by checkTbdPolicy (§18 knobs were inert: QA-13 F4).
|
|
458
|
+
if (!listed('content')) {
|
|
459
|
+
if (deleteMode) {
|
|
460
|
+
rules.content = { tbd_allowed: false, max_tbd_per_file: null };
|
|
461
|
+
}
|
|
398
462
|
} else {
|
|
399
|
-
if (
|
|
463
|
+
if (deleted('content', 'tbd_allowed')) {
|
|
400
464
|
rules.content = { ...rules.content, tbd_allowed: false };
|
|
401
465
|
}
|
|
402
|
-
if (
|
|
466
|
+
if (deleted('content', 'max_tbd_per_file')) {
|
|
403
467
|
rules.content = { ...rules.content, max_tbd_per_file: null };
|
|
404
468
|
}
|
|
405
469
|
}
|
|
406
470
|
|
|
407
471
|
// references: back_pointers / max_hops / orphan_check / duplicate_home_check.
|
|
408
|
-
// `mode` is a parameter — keeps its default when
|
|
472
|
+
// `mode` is a parameter — keeps its default when omitted. max_hops deleted →
|
|
409
473
|
// null → the deep-hop check is skipped entirely (§18 strict: deleted = off;
|
|
410
474
|
// the old deleted → 1 (default) special case violated "delete = off").
|
|
411
|
-
if (!
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
475
|
+
if (!listed('references')) {
|
|
476
|
+
if (deleteMode) {
|
|
477
|
+
rules.references = {
|
|
478
|
+
mode: 'pointer',
|
|
479
|
+
back_pointers: false,
|
|
480
|
+
max_hops: null,
|
|
481
|
+
orphan_check: false,
|
|
482
|
+
duplicate_home_check: false,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
419
485
|
} else {
|
|
420
|
-
if (
|
|
486
|
+
if (deleted('references', 'back_pointers')) {
|
|
421
487
|
rules.references = { ...rules.references, back_pointers: false };
|
|
422
488
|
}
|
|
423
|
-
if (
|
|
489
|
+
if (deleted('references', 'max_hops')) {
|
|
424
490
|
rules.references = { ...rules.references, max_hops: null };
|
|
425
491
|
}
|
|
426
|
-
if (
|
|
492
|
+
if (deleted('references', 'orphan_check')) {
|
|
427
493
|
rules.references = { ...rules.references, orphan_check: false };
|
|
428
494
|
}
|
|
429
|
-
if (
|
|
495
|
+
if (deleted('references', 'duplicate_home_check')) {
|
|
430
496
|
rules.references = { ...rules.references, duplicate_home_check: false };
|
|
431
497
|
}
|
|
432
498
|
}
|
|
433
499
|
|
|
434
500
|
// redundancy: enabled / word_frequency_threshold / phrase_overlap_threshold /
|
|
435
501
|
// cross_file_threshold. `stopwords`/`synonyms` are parameters (§13 inputs) —
|
|
436
|
-
// they keep their defaults when
|
|
502
|
+
// they keep their defaults when omitted so the remaining layers still
|
|
437
503
|
// normalize text exactly as documented.
|
|
438
|
-
if (!
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
504
|
+
if (!listed('redundancy')) {
|
|
505
|
+
if (deleteMode) {
|
|
506
|
+
rules.redundancy = {
|
|
507
|
+
...rules.redundancy,
|
|
508
|
+
enabled: false,
|
|
509
|
+
word_frequency_threshold: null,
|
|
510
|
+
phrase_overlap_threshold: null,
|
|
511
|
+
cross_file_threshold: null,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
446
514
|
} else {
|
|
447
|
-
if (
|
|
515
|
+
if (deleted('redundancy', 'enabled')) {
|
|
448
516
|
rules.redundancy = { ...rules.redundancy, enabled: false };
|
|
449
517
|
}
|
|
450
|
-
if (
|
|
518
|
+
if (deleted('redundancy', 'word_frequency_threshold')) {
|
|
451
519
|
rules.redundancy = { ...rules.redundancy, word_frequency_threshold: null };
|
|
452
520
|
}
|
|
453
|
-
if (
|
|
521
|
+
if (deleted('redundancy', 'phrase_overlap_threshold')) {
|
|
454
522
|
rules.redundancy = { ...rules.redundancy, phrase_overlap_threshold: null };
|
|
455
523
|
}
|
|
456
|
-
if (
|
|
524
|
+
if (deleted('redundancy', 'cross_file_threshold')) {
|
|
457
525
|
rules.redundancy = { ...rules.redundancy, cross_file_threshold: null };
|
|
458
526
|
}
|
|
459
527
|
}
|
|
460
528
|
|
|
461
|
-
// token_budget: warn_threshold deleted → null → no usage warning.
|
|
462
|
-
// parameters (enabled / default_limit / estimate_chars_per_token)
|
|
463
|
-
//
|
|
464
|
-
if (!
|
|
465
|
-
|
|
466
|
-
|
|
529
|
+
// token_budget: warn_threshold omitted (deleted) → null → no usage warning.
|
|
530
|
+
// Planning parameters (enabled / default_limit / estimate_chars_per_token)
|
|
531
|
+
// keep their defaults — §18 budget planning must not change.
|
|
532
|
+
if (!listed('token_budget')) {
|
|
533
|
+
if (deleteMode) {
|
|
534
|
+
rules.token_budget = { ...rules.token_budget, warn_threshold: null };
|
|
535
|
+
}
|
|
536
|
+
} else if (deleted('token_budget', 'warn_threshold')) {
|
|
467
537
|
rules.token_budget = { ...rules.token_budget, warn_threshold: null };
|
|
468
538
|
}
|
|
469
539
|
|
|
470
540
|
// overflow: max_node_chars / force_file_for — both are check keys.
|
|
471
|
-
if (!
|
|
472
|
-
|
|
541
|
+
if (!listed('overflow')) {
|
|
542
|
+
if (deleteMode) {
|
|
543
|
+
rules.overflow = { max_node_chars: null, force_file_for: null };
|
|
544
|
+
}
|
|
473
545
|
} else {
|
|
474
|
-
if (
|
|
546
|
+
if (deleted('overflow', 'max_node_chars')) {
|
|
475
547
|
rules.overflow = { ...rules.overflow, max_node_chars: null };
|
|
476
548
|
}
|
|
477
|
-
if (
|
|
549
|
+
if (deleted('overflow', 'force_file_for')) {
|
|
478
550
|
rules.overflow = { ...rules.overflow, force_file_for: null };
|
|
479
551
|
}
|
|
480
552
|
}
|
package/src/core/structure.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { OutlineNode, Issue, StructureRules } from '../types';
|
|
1
|
+
import type { OutlineNode, Issue, StructureRules, ContentRules } from '../types';
|
|
2
|
+
import { flattenNodes } from './outline';
|
|
2
3
|
|
|
3
4
|
/** Structure checks: node length, depth, sibling count, single-child collapse, empty nodes.
|
|
4
5
|
* §18 delete-key semantics: a check whose rules key is null/false is OFF — the
|
|
@@ -84,3 +85,42 @@ export function checkStructure(
|
|
|
84
85
|
walk(nodes);
|
|
85
86
|
return issues;
|
|
86
87
|
}
|
|
88
|
+
|
|
89
|
+
/** §18 content rules — TBD policy per file (QA-13 F4: the knobs were inert).
|
|
90
|
+
* `tbd_allowed: false` → any TBD node is flagged; otherwise `max_tbd_per_file`
|
|
91
|
+
* caps the number of TBD nodes per file (deleted key → null → no cap). One
|
|
92
|
+
* warning per file: §4 keeps TBDs first-class, so exceeding the policy is
|
|
93
|
+
* advisory and never affects the exit code (§19). */
|
|
94
|
+
export function checkTbdPolicy(
|
|
95
|
+
nodes: OutlineNode[],
|
|
96
|
+
file: string,
|
|
97
|
+
rules: ContentRules,
|
|
98
|
+
): Issue[] {
|
|
99
|
+
const tbdNodes = flattenNodes(nodes).filter(n => /\bTBD\b/i.test(n.text));
|
|
100
|
+
if (tbdNodes.length === 0) return [];
|
|
101
|
+
if (!rules.tbd_allowed) {
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
file,
|
|
105
|
+
line: tbdNodes[0]!.line,
|
|
106
|
+
level: 'warning',
|
|
107
|
+
category: 'structure',
|
|
108
|
+
message: 'TBD used but content.tbd_allowed is false',
|
|
109
|
+
suggestion: 'resolve the TBD nodes or set content.tbd_allowed: true',
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
if (rules.max_tbd_per_file !== null && tbdNodes.length > rules.max_tbd_per_file) {
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
file,
|
|
117
|
+
line: tbdNodes[0]!.line,
|
|
118
|
+
level: 'warning',
|
|
119
|
+
category: 'structure',
|
|
120
|
+
message: `${tbdNodes.length} TBD nodes exceed content.max_tbd_per_file (${rules.max_tbd_per_file})`,
|
|
121
|
+
suggestion: 'resolve the TBD nodes or raise content.max_tbd_per_file',
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
}
|
|
125
|
+
return [];
|
|
126
|
+
}
|
package/src/core/token-budget.ts
CHANGED
|
File without changes
|
package/templates/AGENTS.md
CHANGED
|
File without changes
|
package/templates/_rules.yaml
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|