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 CHANGED
File without changes
package/package.json CHANGED
@@ -1,16 +1,25 @@
1
1
  {
2
2
  "name": "cans-spec",
3
- "version": "0.1.0",
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": { "cans": "./src/cli.ts" },
5
+ "bin": {
6
+ "cans": "./src/cli.ts"
7
+ },
6
8
  "type": "module",
7
- "engines": { "bun": ">=1.0.0" },
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": ["src/", "templates/", "README.md", "LICENSE"],
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
@@ -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, isFile } from '../core/fs';
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
- // --change: center the plan on an active task file.
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 p = join(workspace, '_tasks', `${opts.change}.md`);
179
- if (isFile(p)) taskFile = p;
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
- // §26 step 3: active tasks mentioning the concept join the plan (score 80).
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,
@@ -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));
@@ -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 ≥ 0.75 overlap.
102
- * Threshold note: the pasted patch said 0.8, but the QA-05 F8 flagship conflict
103
- * pair — "Expire after 24 hours" vs "Expire after 48 hours" — shares 3 of 4
104
- * words (0.75) and MUST be flagged, so the threshold is tuned to 0.75.
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) >= 0.75;
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 (const imp of importNodes) {
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 a new NN-slug.md file.
384
- const existingRel = findExistingBySlug(workspace, slug);
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(
File without changes
File without changes
@@ -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), strips `((block-refs))`, `[[wiki]]` → `see:`
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
- if (/^[\w\s-]+::/.test(clean)) continue; // pure property line → drop
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
- /** Load rules from `<root>/_rules.yaml` deep-merged over defaults.
306
- * Missing file → all defaults.
307
- * File exists → §18 "Delete a key = check turns off": sections/keys absent
308
- * from the file disable their checks instead of keeping defaults.
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 "Delete a key = check turns off" — applied AFTER validation, so every
322
- // absent check-key is flipped from its deep-merged default to its OFF state:
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 deleted, §18 overrides only
333
- // what the file lists for these): mode, stopwords, synonyms,
334
- // estimate_chars_per_token, default_limit; token_budget.enabled is a
335
- // planning switch, not a check switch, so it too keeps its default.
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 (!topLevel.has('structure')) {
347
- rules.structure = {
348
- node_length: offRange(),
349
- siblings: offRange(),
350
- depth: offRange(),
351
- single_child_collapse: false,
352
- empty_nodes: false,
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 (!has('structure', 'node_length')) {
414
+ if (deleted('structure', 'node_length')) {
356
415
  rules.structure = { ...rules.structure, node_length: offRange() };
357
416
  }
358
- if (!has('structure', 'siblings')) {
417
+ if (deleted('structure', 'siblings')) {
359
418
  rules.structure = { ...rules.structure, siblings: offRange() };
360
419
  }
361
- if (!has('structure', 'depth')) {
420
+ if (deleted('structure', 'depth')) {
362
421
  rules.structure = { ...rules.structure, depth: offRange() };
363
422
  }
364
- if (!has('structure', 'single_child_collapse')) {
423
+ if (deleted('structure', 'single_child_collapse')) {
365
424
  rules.structure = { ...rules.structure, single_child_collapse: false };
366
425
  }
367
- if (!has('structure', 'empty_nodes')) {
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 (!topLevel.has('style')) {
374
- rules.style = {
375
- prefer: null,
376
- force_nested_above: null,
377
- force_sibling_below: null,
378
- shared_prefix_detection: false,
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 (!has('style', 'prefer')) {
442
+ if (deleted('style', 'prefer')) {
382
443
  rules.style = { ...rules.style, prefer: null };
383
444
  }
384
- if (!has('style', 'force_nested_above')) {
445
+ if (deleted('style', 'force_nested_above')) {
385
446
  rules.style = { ...rules.style, force_nested_above: null };
386
447
  }
387
- if (!has('style', 'force_sibling_below')) {
448
+ if (deleted('style', 'force_sibling_below')) {
388
449
  rules.style = { ...rules.style, force_sibling_below: null };
389
450
  }
390
- if (!has('style', 'shared_prefix_detection')) {
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
- if (!topLevel.has('content')) {
397
- rules.content = { tbd_allowed: false, max_tbd_per_file: null };
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 (!has('content', 'tbd_allowed')) {
463
+ if (deleted('content', 'tbd_allowed')) {
400
464
  rules.content = { ...rules.content, tbd_allowed: false };
401
465
  }
402
- if (!has('content', 'max_tbd_per_file')) {
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 deleted. max_hops deleted →
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 (!topLevel.has('references')) {
412
- rules.references = {
413
- mode: 'pointer',
414
- back_pointers: false,
415
- max_hops: null,
416
- orphan_check: false,
417
- duplicate_home_check: false,
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 (!has('references', 'back_pointers')) {
486
+ if (deleted('references', 'back_pointers')) {
421
487
  rules.references = { ...rules.references, back_pointers: false };
422
488
  }
423
- if (!has('references', 'max_hops')) {
489
+ if (deleted('references', 'max_hops')) {
424
490
  rules.references = { ...rules.references, max_hops: null };
425
491
  }
426
- if (!has('references', 'orphan_check')) {
492
+ if (deleted('references', 'orphan_check')) {
427
493
  rules.references = { ...rules.references, orphan_check: false };
428
494
  }
429
- if (!has('references', 'duplicate_home_check')) {
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 deleted so the remaining layers still
502
+ // they keep their defaults when omitted so the remaining layers still
437
503
  // normalize text exactly as documented.
438
- if (!topLevel.has('redundancy')) {
439
- rules.redundancy = {
440
- ...rules.redundancy,
441
- enabled: false,
442
- word_frequency_threshold: null,
443
- phrase_overlap_threshold: null,
444
- cross_file_threshold: null,
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 (!has('redundancy', 'enabled')) {
515
+ if (deleted('redundancy', 'enabled')) {
448
516
  rules.redundancy = { ...rules.redundancy, enabled: false };
449
517
  }
450
- if (!has('redundancy', 'word_frequency_threshold')) {
518
+ if (deleted('redundancy', 'word_frequency_threshold')) {
451
519
  rules.redundancy = { ...rules.redundancy, word_frequency_threshold: null };
452
520
  }
453
- if (!has('redundancy', 'phrase_overlap_threshold')) {
521
+ if (deleted('redundancy', 'phrase_overlap_threshold')) {
454
522
  rules.redundancy = { ...rules.redundancy, phrase_overlap_threshold: null };
455
523
  }
456
- if (!has('redundancy', 'cross_file_threshold')) {
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. Planning
462
- // parameters (enabled / default_limit / estimate_chars_per_token) keep their
463
- // defaults when deleted — §18 budget planning must not change.
464
- if (!topLevel.has('token_budget')) {
465
- rules.token_budget = { ...rules.token_budget, warn_threshold: null };
466
- } else if (!has('token_budget', 'warn_threshold')) {
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 (!topLevel.has('overflow')) {
472
- rules.overflow = { max_node_chars: null, force_file_for: null };
541
+ if (!listed('overflow')) {
542
+ if (deleteMode) {
543
+ rules.overflow = { max_node_chars: null, force_file_for: null };
544
+ }
473
545
  } else {
474
- if (!has('overflow', 'max_node_chars')) {
546
+ if (deleted('overflow', 'max_node_chars')) {
475
547
  rules.overflow = { ...rules.overflow, max_node_chars: null };
476
548
  }
477
- if (!has('overflow', 'force_file_for')) {
549
+ if (deleted('overflow', 'force_file_for')) {
478
550
  rules.overflow = { ...rules.overflow, force_file_for: null };
479
551
  }
480
552
  }
@@ -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
+ }
File without changes
File without changes
File without changes
File without changes
File without changes