unknown-knowledge 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +167 -0
  4. package/cli/.gitkeep +0 -0
  5. package/cli/commands/init-copy.js +90 -0
  6. package/cli/commands/init.js +386 -0
  7. package/cli/init-copy.js +24 -0
  8. package/cli/init.js +24 -0
  9. package/cli/kit.manifest.yaml +256 -0
  10. package/cli/lib/copy-payload.js +334 -0
  11. package/cli/lib/generate-wrappers.js +162 -0
  12. package/package.json +36 -0
  13. package/payload/adapter-fixtures/README.md +53 -0
  14. package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
  15. package/payload/adapter-fixtures/html/sample.html +38 -0
  16. package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
  17. package/payload/adapter-fixtures/md/sample.md +32 -0
  18. package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
  19. package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
  20. package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
  21. package/payload/adapter-fixtures/txt/sample.txt +18 -0
  22. package/payload/docs/README.md +102 -0
  23. package/payload/docs/boundaries.md +60 -0
  24. package/payload/docs/ci-wiring.md +109 -0
  25. package/payload/docs/steward-guide.md +238 -0
  26. package/payload/engine/audit.js +34 -0
  27. package/payload/engine/commands/audit.js +289 -0
  28. package/payload/engine/commands/derive.js +334 -0
  29. package/payload/engine/commands/ingest.js +124 -0
  30. package/payload/engine/commands/log-entry.js +85 -0
  31. package/payload/engine/commands/phoenix.js +206 -0
  32. package/payload/engine/commands/preflight.js +530 -0
  33. package/payload/engine/commands/resolve.js +1678 -0
  34. package/payload/engine/commands/survey-map.js +351 -0
  35. package/payload/engine/commands/validate-values.js +315 -0
  36. package/payload/engine/commands/validate.js +1426 -0
  37. package/payload/engine/derive.js +34 -0
  38. package/payload/engine/ingest.js +34 -0
  39. package/payload/engine/lib/anchor-signatures.js +126 -0
  40. package/payload/engine/lib/boot.js +39 -0
  41. package/payload/engine/lib/call-numbers.js +133 -0
  42. package/payload/engine/lib/cli.js +147 -0
  43. package/payload/engine/lib/coverage.js +849 -0
  44. package/payload/engine/lib/decomposition.js +225 -0
  45. package/payload/engine/lib/derived.js +494 -0
  46. package/payload/engine/lib/engine-refusal.js +40 -0
  47. package/payload/engine/lib/exit-codes.js +14 -0
  48. package/payload/engine/lib/extractor-kinds.js +955 -0
  49. package/payload/engine/lib/format-adapters.js +802 -0
  50. package/payload/engine/lib/id-grammars.js +178 -0
  51. package/payload/engine/lib/iso-date.js +55 -0
  52. package/payload/engine/lib/kit-root.js +101 -0
  53. package/payload/engine/lib/load-stores.js +1624 -0
  54. package/payload/engine/lib/log-entry.js +196 -0
  55. package/payload/engine/lib/phoenix.js +628 -0
  56. package/payload/engine/lib/scoring.js +150 -0
  57. package/payload/engine/lib/suppressions.js +172 -0
  58. package/payload/engine/lib/time-verdicts.js +282 -0
  59. package/payload/engine/lib/usage-error.js +14 -0
  60. package/payload/engine/lib/validate-record.js +504 -0
  61. package/payload/engine/log-entry.js +34 -0
  62. package/payload/engine/phoenix.js +39 -0
  63. package/payload/engine/preflight.js +34 -0
  64. package/payload/engine/resolve.js +34 -0
  65. package/payload/engine/survey-map.js +34 -0
  66. package/payload/engine/validate-values.js +34 -0
  67. package/payload/engine/validate.js +34 -0
  68. package/payload/extractor-fixtures/.gitkeep +0 -0
  69. package/payload/extractor-fixtures/README.md +29 -0
  70. package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
  71. package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
  72. package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
  73. package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
  74. package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
  75. package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
  76. package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
  77. package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
  78. package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
  79. package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
  80. package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
  81. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
  82. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
  83. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
  84. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
  85. package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
  86. package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
  87. package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
  88. package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
  89. package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
  90. package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
  91. package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
  92. package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
  93. package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
  94. package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
  95. package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
  96. package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
  97. package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
  98. package/payload/hooks/pre-commit +37 -0
  99. package/payload/hooks/reverse-lookup +66 -0
  100. package/payload/package.json +3 -0
  101. package/payload/protocol/.gitkeep +0 -0
  102. package/payload/protocol/AGENTS.md +239 -0
  103. package/payload/protocol/derived-layer.md +174 -0
  104. package/payload/protocol/new-kind-pipeline.md +179 -0
  105. package/payload/protocol/registry-warrant.md +162 -0
  106. package/payload/protocol/skills/kb-build.md +303 -0
  107. package/payload/protocol/skills/knowledge-audit.md +183 -0
  108. package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
  109. package/payload/protocol/skills/knowledge-reflect.md +397 -0
  110. package/payload/schemas/catalog.schema.json +32 -0
  111. package/payload/schemas/decision-entry.schema.json +122 -0
  112. package/payload/schemas/finding.schema.json +77 -0
  113. package/payload/schemas/gap.schema.json +52 -0
  114. package/payload/schemas/graduation-categories.schema.json +64 -0
  115. package/payload/schemas/knowledge-leaf.schema.json +194 -0
  116. package/payload/schemas/miss.schema.json +45 -0
  117. package/payload/schemas/ontology-concept.schema.json +115 -0
  118. package/payload/schemas/phoenix-event.schema.json +76 -0
  119. package/payload/schemas/registry.schema.json +57 -0
  120. package/payload/schemas/rules.schema.json +14 -0
  121. package/payload/schemas/survey-scope.schema.json +23 -0
  122. package/payload/templates/decisions/_catalog.yaml +7 -0
  123. package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
  124. package/payload/templates/decisions/phoenix-event.yaml +74 -0
  125. package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
  126. package/payload/templates/decisions/registry-minting.yaml +58 -0
  127. package/payload/templates/decisions/trust-graduation.yaml +120 -0
  128. package/payload/templates/decisions/trust-revocation.yaml +106 -0
  129. package/payload/templates/knowledge/_catalog.yaml +9 -0
  130. package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
  131. package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
  132. package/payload/templates/knowledge/_registries/domains.yaml +43 -0
  133. package/payload/templates/knowledge/_registries/form.yaml +38 -0
  134. package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
  135. package/payload/templates/knowledge/_registries/operations.yaml +18 -0
  136. package/payload/templates/knowledge/_registries/stage.yaml +53 -0
  137. package/payload/templates/knowledge/_rules.yaml +6 -0
  138. package/payload/templates/new-kind/README.md +107 -0
  139. package/payload/templates/new-kind/descriptor.example.yaml +18 -0
  140. package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
  141. package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
  142. package/payload/templates/new-kind/fixture/sample.list +7 -0
  143. package/payload/templates/new-kind/parser.example.js +98 -0
  144. package/payload/templates/ontology/_catalog.yaml +6 -0
  145. package/payload/templates/ontology/_rules.yaml +6 -0
  146. package/payload/wrappers/cursor.mdc +15 -0
  147. package/payload/wrappers/pointer.md +10 -0
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/derive.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/derive.js. Its invocation path is unchanged.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/derive.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('derive', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so no check ran. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`derive: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * engine/ingest.js — the entry shim (UCS-956).
4
+ *
5
+ * This file statically imports NOTHING. That is its entire job.
6
+ *
7
+ * Node exits 1 on an unhandled ES module load error, and exit 1 means FINDINGS
8
+ * (PRD §5). A SyntaxError in a lib/ module, or a missing `js-yaml`, must not
9
+ * tell an agent that the check ran and found problems. So the engine is reached
10
+ * only through `import()`, where a load failure is an ordinary catchable
11
+ * rejection rather than a process-level crash.
12
+ *
13
+ * Both specifiers are string literals naming the engine's own files. D-014
14
+ * forbids importing REPO CONTENT — the client's code — and nothing here can
15
+ * name it: there is no variable to point somewhere else.
16
+ *
17
+ * The command lives in commands/ingest.js.
18
+ */
19
+ try {
20
+ const [{ boot }, command] = await Promise.all([
21
+ import('./lib/boot.js'),
22
+ import('./commands/ingest.js'),
23
+ ]);
24
+ // exitCode, never process.exit(): exit() drops queued async stdout writes, so
25
+ // piped --json output would truncate at the pipe buffer — corrupt output
26
+ // wearing a clean exit code. Node exits on its own once stdout drains.
27
+ process.exitCode = await boot('ingest', command);
28
+ } catch (error) {
29
+ // The engine could not be loaded, so nothing was adapted. Exit 2 — never 1.
30
+ // Hardcoded, because reading it from lib/exit-codes.js is the very thing
31
+ // that may have just failed.
32
+ process.stderr.write(`ingest: internal failure — the engine could not be loaded\n${error?.stack ?? error}\n`);
33
+ process.exitCode = 2;
34
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Anchor-candidate signatures (KK-25) — ONE regex table shared between the
3
+ * survey map's pre-scan and the extractor kinds (KK-08/09 import from here).
4
+ * A kind describes the shape of a declaration, not the file type (PRD §5.1):
5
+ * .tsx/.jsx are extensions handled by the TS/JS kinds, not separate kinds.
6
+ *
7
+ * Each signature is a cheap lexical sniff — "this file plausibly contains an
8
+ * anchor of this kind" — never a parse. Extraction (and the syntactic-envelope
9
+ * hard-error discipline) belongs to the extractor recipes; the survey map only
10
+ * surfaces candidates for agent triage. Patterns are stored as source strings
11
+ * so the table stays data (importable, testable, greppable), compiled by
12
+ * consumers. `pattern: null` marks a directory-shape kind (dir-modules) that
13
+ * is detected structurally, not by content.
14
+ *
15
+ * Sorted by kind; a kind may carry several signatures when one anchor shape
16
+ * lives in more than one file format (strings-keys: legacy `.strings` and the
17
+ * JSON `.xcstrings` catalog). DEEP-frozen — the entries AND their extensions
18
+ * arrays — so extending the kind set stays a KK-08/09-style change with
19
+ * fixtures, never an in-session mutation (D-005).
20
+ *
21
+ * Known limitation: legacy UTF-16 .strings files (BOM-marked, common in older
22
+ * Xcode exports) do not decode as UTF-8, so a content sniff cannot see their
23
+ * `"key" = "value"` lines. The survey map treats a UTF-16 BOM as "candidate by
24
+ * extension" instead of skipping silently — see sniffKinds() in survey-map.js.
25
+ */
26
+ const TS_EXTENSIONS = Object.freeze(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
27
+ const YAML_EXTENSIONS = Object.freeze(['.yaml', '.yml']);
28
+
29
+ export const ANCHOR_SIGNATURES = Object.freeze([
30
+ {
31
+ kind: 'dir-modules',
32
+ extensions: null, // directory shape: siblings sharing one extension
33
+ pattern: null,
34
+ flags: '',
35
+ reads: 'subfolder/file names, with pattern/strip options',
36
+ },
37
+ {
38
+ kind: 'json-keys',
39
+ extensions: ['.json'],
40
+ pattern: '^\\s*"[^"\\n]+"\\s*:',
41
+ flags: 'm',
42
+ reads: 'top-level keys of a JSON object',
43
+ },
44
+ {
45
+ kind: 'json-map-keys',
46
+ extensions: ['.json'],
47
+ pattern: '"[^"\\n]+"\\s*:\\s*\\{',
48
+ flags: 'm',
49
+ reads: 'keys under a dotted path in a JSON object',
50
+ },
51
+ {
52
+ kind: 'strings-keys',
53
+ extensions: ['.strings'],
54
+ pattern: '^\\s*"[^"\\n]+"\\s*=\\s*"',
55
+ flags: 'm',
56
+ reads: 'localization keys of a legacy `"key" = "value"` .strings table',
57
+ },
58
+ {
59
+ kind: 'strings-keys',
60
+ extensions: ['.xcstrings'],
61
+ pattern: '"strings"\\s*:\\s*\\{',
62
+ flags: 'm',
63
+ reads: 'localization keys of an .xcstrings JSON catalog ("strings" map)',
64
+ },
65
+ {
66
+ kind: 'swift-const-array',
67
+ extensions: ['.swift'],
68
+ pattern: '\\b(?:static\\s+)?let\\s+\\w+\\s*(?::\\s*\\[[^\\]\\n]*\\])?\\s*=\\s*\\[',
69
+ flags: 'm',
70
+ reads: 'static let / let array literals',
71
+ },
72
+ {
73
+ kind: 'swift-enum',
74
+ extensions: ['.swift'],
75
+ pattern: '\\benum\\s+\\w+\\s*(?::\\s*[^{\\n]+)?\\{',
76
+ flags: 'm',
77
+ reads: 'cases of a Swift enum',
78
+ },
79
+ {
80
+ kind: 'ts-const-array',
81
+ extensions: TS_EXTENSIONS,
82
+ pattern: '\\bexport\\s+(?:const|let|var)\\s+\\w+\\s*(?::\\s*[^=\\n]+)?=\\s*\\[',
83
+ flags: 'm',
84
+ reads: 'exported const array (works for JS too)',
85
+ },
86
+ {
87
+ kind: 'ts-enum',
88
+ extensions: TS_EXTENSIONS,
89
+ pattern: '\\b(?:export\\s+)?(?:const\\s+)?enum\\s+\\w+\\s*\\{',
90
+ flags: 'm',
91
+ reads: 'TS enum members',
92
+ },
93
+ {
94
+ kind: 'ts-object-keys',
95
+ extensions: TS_EXTENSIONS,
96
+ pattern: '\\bexport\\s+(?:const|let)\\s+\\w+\\s*(?::\\s*[^=\\n]+)?=\\s*\\{',
97
+ flags: 'm',
98
+ reads: 'keys of an exported object literal',
99
+ },
100
+ {
101
+ kind: 'ts-union',
102
+ extensions: TS_EXTENSIONS,
103
+ pattern: "\\btype\\s+\\w+\\s*=\\s*[^;\\n]*['\"][^'\"\\n]*['\"]\\s*\\|",
104
+ flags: 'm',
105
+ reads: 'string-literal union members',
106
+ },
107
+ {
108
+ kind: 'yaml-keys',
109
+ extensions: YAML_EXTENSIONS,
110
+ pattern: '^[A-Za-z_][\\w.-]*\\s*:',
111
+ flags: 'm',
112
+ reads: 'top-level keys of a YAML document',
113
+ },
114
+ {
115
+ kind: 'yaml-map-keys',
116
+ extensions: YAML_EXTENSIONS,
117
+ pattern: '^[A-Za-z_][\\w.-]*\\s*:\\s*$',
118
+ flags: 'm',
119
+ reads: 'keys under a dotted path in a YAML document',
120
+ },
121
+ ].map((sig) => {
122
+ // Deep freeze: a mutable (or aliased) extensions array would let a push
123
+ // onto one kind silently rewire every kind sharing the same array.
124
+ if (sig.extensions !== null) Object.freeze(sig.extensions);
125
+ return Object.freeze(sig);
126
+ }));
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Load a command, then run it through the harness (UCS-956).
3
+ *
4
+ * `runCli` promises that a crash exits 2, never 1. It cannot keep that promise
5
+ * for a failure that happens BEFORE it loads: Node exits 1 on an unhandled ES
6
+ * module load error, so a SyntaxError in any lib/ module — or a missing
7
+ * `js-yaml` — used to make every engine surface exit 1, the FINDINGS code, with
8
+ * the harness never having run.
9
+ *
10
+ * That is the worst possible failure for the exit-code contract (PRD §5,
11
+ * D-011). An agent reads exit 1 as "the check ran and found problems", so it
12
+ * quarantines and continues — past a check that never ran. And it is MORE
13
+ * reachable than an ordinary crash, because the kit is seeded into a client
14
+ * repo (D-001): a partial copy, a corrupted file, or an uninstalled dependency
15
+ * lands exactly here.
16
+ *
17
+ * So the entry point of every surface is a shim that statically imports
18
+ * NOTHING, and reaches the engine only through `import()`, whose failure is an
19
+ * ordinary catchable rejection. Everything the engine is made of — including
20
+ * `cli.js`, and this module — loads inside that seam.
21
+ *
22
+ * This module is loaded dynamically too. If it cannot load, the shim's own
23
+ * catch reports it and exits 2.
24
+ */
25
+ import process from 'node:process';
26
+ import { runCli } from './cli.js';
27
+
28
+ /**
29
+ * The shim performs the `import()` itself, with a string-literal specifier, and
30
+ * hands the loaded module here. Nothing in the engine ever imports a computed
31
+ * path — there is no variable that could be made to name client code (D-014).
32
+ *
33
+ * @param {string} name the command's name, for its messages
34
+ * @param {{ main: (argv: string[]) => number | Promise<number>, USAGE: string }} command
35
+ * @returns {Promise<number>} an exit code; 1 only if the command returned it
36
+ */
37
+ export async function boot(name, command) {
38
+ return runCli(name, command.main, { usage: command.USAGE });
39
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Synthesized call numbers (UCS-1158) — a display string, and never an identity.
3
+ *
4
+ * A call number is what a browse tree PRINTS beside a leaf so a reader scanning
5
+ * a shelf has a compact handle for where that leaf sits in this particular
6
+ * projection. It is derived, every time, from the leaf's facets. It is not
7
+ * stored, not indexed, not resolvable, and not citable.
8
+ *
9
+ * That last part is the whole reason this module is careful rather than
10
+ * cosmetic. The store this engine serves spent its first life with a Dewey-style
11
+ * dotted notation that fused identity, location and browse order into one
12
+ * string, and the cost of that fusion is what UCS-1147 spent a migration
13
+ * undoing: a citation pointed at a position, so moving a leaf broke every
14
+ * reference to it. A synthesized call number is the SAME SHAPE of string
15
+ * arriving through a different door — positional, human-friendly, and highly
16
+ * tempting to paste into a `see-also`. If that paste ever resolved, the whole
17
+ * inversion would quietly reverse itself one convenient citation at a time.
18
+ *
19
+ * So the defense is structural rather than advisory, in three parts:
20
+ *
21
+ * 1. The grammar cannot be mistaken for an id. `L-000117` is the accession;
22
+ * a call number looks like `DES-COM/REF·L-000117` — uppercase facet
23
+ * abbreviations, a slash, and a MIDDLE DOT that no id grammar accepts.
24
+ * `assertNoIdSpaceMatches` (below) proves it against every compiled id
25
+ * space in ID_GRAMMARS rather than against a remembered list, so a new id
26
+ * space cannot silently start matching call numbers.
27
+ * 2. Nothing accepts one. The citation grammar is `leaf-ref`, accession-only
28
+ * since UCS-1147, so a call number in a citation-shaped field is already a
29
+ * finding — no new check was needed, and tests/derived-call-numbers pins
30
+ * that it stays that way.
31
+ * 3. The accession travels INSIDE the call number, as its suffix. A reader
32
+ * who copies the whole string still carries the identity, and a reader who
33
+ * wants the citable part can see exactly which half it is. The tempting
34
+ * thing to paste therefore contains the correct thing to paste.
35
+ *
36
+ * The call number is NOT stable across projections, and that is a feature: the
37
+ * same leaf reads `DES-COM/REF·L-000117` in the domain-first tree and
38
+ * `REF/DES-COM·L-000117` in the form-first one, because the call number
39
+ * describes a POSITION in a projection, and that leaf holds two of them. A
40
+ * string that changed meaning between trees while looking stable would be the
41
+ * worst of both worlds; one that visibly reorders says what it is.
42
+ */
43
+ import { idPattern, ID_GRAMMARS } from './id-grammars.js';
44
+
45
+ /**
46
+ * The separator between the positional part and the accession.
47
+ *
48
+ * U+00B7 MIDDLE DOT, chosen because no id grammar in this engine accepts it and
49
+ * none plausibly will: the id spaces are all `[A-Z]-[0-9]` or dotted decimals,
50
+ * and a middle dot is not a character an author reaches for when minting an id.
51
+ * It is also visually distinct from the ASCII period that the LEGACY notation
52
+ * used, so `362.1` and `DES-COM/REF·L-000117` cannot be skim-confused.
53
+ */
54
+ export const CALL_NUMBER_SEPARATOR = '·';
55
+
56
+ /** How many characters of each facet segment survive into the abbreviation. */
57
+ const SEGMENT_WIDTH = 3;
58
+
59
+ /** What a facet segment abbreviates to when it is missing entirely. */
60
+ const ABSENT_SEGMENT = 'UNC';
61
+
62
+ /**
63
+ * Abbreviate one facet segment for display.
64
+ *
65
+ * Uppercased and truncated to three characters, with non-alphanumerics dropped
66
+ * so a segment like `components` reads `COM` rather than `COM-`. Deliberately
67
+ * LOSSY: two segments can abbreviate to the same three letters, and that is
68
+ * fine because nothing resolves through this string — the accession suffix is
69
+ * what disambiguates, and it is exact. Making the abbreviation collision-free
70
+ * would mean minting and remembering it, which is precisely the property that
71
+ * would turn a display string back into an identity.
72
+ *
73
+ * An absent or non-string segment reads `UNC` (unclassified) rather than
74
+ * collapsing to an empty string. An empty abbreviation would make
75
+ * `/REF·L-000117` — a call number with a hole where a facet should be, which
76
+ * reads as a formatting bug rather than as the fact that the leaf declares no
77
+ * domain. The tree also demotes such a leaf; the call number says why.
78
+ *
79
+ * @param {unknown} segment one facet path segment
80
+ * @returns {string} the display abbreviation
81
+ */
82
+ export function abbreviateSegment(segment) {
83
+ if (typeof segment !== 'string') return ABSENT_SEGMENT;
84
+ const cleaned = segment.replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
85
+ return cleaned === '' ? ABSENT_SEGMENT : cleaned.slice(0, SEGMENT_WIDTH);
86
+ }
87
+
88
+ /**
89
+ * Synthesize the call number for one leaf, in one projection.
90
+ *
91
+ * The positional half is the projection's own path — the same segments the tree
92
+ * nested this leaf under, abbreviated and slash-joined — so the call number and
93
+ * the position it describes cannot disagree: both are built from one array.
94
+ * That is the invariant worth protecting, and it is why this function takes the
95
+ * path rather than re-deriving it from the leaf's facets. A second derivation
96
+ * would be a second chance to differ.
97
+ *
98
+ * The identity half is the accession, verbatim. A leaf that mints none (which
99
+ * the schema refuses, but the derived layer never gates on store health) prints
100
+ * the absent marker instead, so the string stays well-formed and the missing
101
+ * identity is visible rather than swallowed.
102
+ *
103
+ * @param {string[]} path the projection's facet path for this leaf
104
+ * @param {string|null} accession the leaf's accession id, or null
105
+ * @returns {string} the synthesized display string
106
+ */
107
+ export function synthesizeCallNumber(path, accession) {
108
+ const positional = path.length
109
+ ? path.map(abbreviateSegment).join('/')
110
+ : ABSENT_SEGMENT;
111
+ const identity = typeof accession === 'string' && accession !== '' ? accession : ABSENT_SEGMENT;
112
+ return `${positional}${CALL_NUMBER_SEPARATOR}${identity}`;
113
+ }
114
+
115
+ /**
116
+ * Prove a synthesized call number is not an id in ANY space this engine knows.
117
+ *
118
+ * Enumerates ID_GRAMMARS rather than checking a remembered list of spaces, so
119
+ * adding an id space that happened to accept middle dots would fail here at the
120
+ * moment it was added rather than the first time somebody cited a shelf label.
121
+ * Returns the offending spaces rather than throwing: the caller decides whether
122
+ * that is a test failure or a finding, and a library that threw would make the
123
+ * check unusable from the one place it matters most — a test asserting the
124
+ * property holds for every call number a real store generates.
125
+ *
126
+ * @param {string} callNumber a synthesized display string
127
+ * @returns {string[]} the id spaces that would accept it — empty when safe
128
+ */
129
+ export function idSpacesMatching(callNumber) {
130
+ return Object.keys(ID_GRAMMARS)
131
+ .filter((space) => idPattern(space).test(callNumber))
132
+ .sort();
133
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The CLI shell, once (UCS-944).
3
+ *
4
+ * Nine command-line surfaces each hand-roll the same flag grammar, the same
5
+ * usage-error type, and the same mapping from outcome to exit code. The copies
6
+ * have already drifted — the `--concepts` filter meant two different things to
7
+ * two validators until UCS-935 settled it.
8
+ *
9
+ * The safety rule they restate is the load-bearing one:
10
+ *
11
+ * EXIT 1 MEANS FINDINGS. A crash must never wear it.
12
+ *
13
+ * An engine that dies mid-check and exits 1 tells an agent riding the exit-code
14
+ * contract that the check RAN and found problems — so it quarantines and
15
+ * continues, past a check that never ran (PRD §5, D-011). Today that rule is
16
+ * enforced by each author remembering to copy a catch block, and this session
17
+ * found FIVE surfaces where the copy was wrong or missing.
18
+ *
19
+ * `runCli` owns the epilogue instead: a usage error reports and exits 2, and
20
+ * ANY unexpected throw reports and exits 2. Exit 1 is reachable only by a main
21
+ * that deliberately returns it, having actually run and actually found things.
22
+ *
23
+ * Every surface reaches this through `lib/boot.js`, from an entry shim that
24
+ * statically imports nothing (UCS-956). The nine hand-written shells that this
25
+ * replaced are gone (UCS-952).
26
+ */
27
+ import process from 'node:process';
28
+ import { EXIT_CODES } from './exit-codes.js';
29
+ import { UsageError } from './usage-error.js';
30
+
31
+ export { UsageError } from './usage-error.js';
32
+ export { EngineRefusal, rethrowIfBug } from './engine-refusal.js';
33
+
34
+ /**
35
+ * Parse the flag grammar every engine CLI shares.
36
+ *
37
+ * Both conventional spellings work — `--flag value` and `--flag=value` — and
38
+ * everything the grammar cannot account for is a UsageError, never a guess:
39
+ * an unknown flag, a stray positional where none is allowed, a value flag with
40
+ * no value, a boolean flag given one, or an empty value (`--root=` would
41
+ * otherwise resolve to the current directory, answering about a repo nobody
42
+ * named).
43
+ *
44
+ * @param {string[]} argv arguments after the script name
45
+ * @param {object} spec
46
+ * @param {string[]} [spec.boolean] flags that take no value
47
+ * @param {string[]} [spec.value] flags taking one value; last wins
48
+ * @param {string[]} [spec.repeatable] value flags that accumulate into an array
49
+ * @param {string[]} [spec.allowEmpty] value flags for which "" is a legal value
50
+ * — preflight reads an empty `--concepts` as store-health-only (PRD §7)
51
+ * @param {boolean} [spec.positionals] whether bare arguments are allowed
52
+ * @returns {{ options: object, positionals: string[] }}
53
+ */
54
+ export function parseArgs(argv, spec = {}) {
55
+ const booleans = new Set(spec.boolean ?? []);
56
+ const values = new Set(spec.value ?? []);
57
+ const repeatables = new Set(spec.repeatable ?? []);
58
+ const allowEmpty = new Set(spec.allowEmpty ?? []);
59
+ const known = new Set([...booleans, ...values, ...repeatables]);
60
+ const options = {};
61
+ const positionals = [];
62
+
63
+ for (let i = 0; i < argv.length; i += 1) {
64
+ const arg = argv[i];
65
+ if (!arg.startsWith('--')) {
66
+ if (!spec.positionals) {
67
+ throw new UsageError(`unexpected argument ${JSON.stringify(arg)} — this CLI takes flags only`);
68
+ }
69
+ positionals.push(arg);
70
+ continue;
71
+ }
72
+
73
+ const eq = arg.indexOf('=');
74
+ const flag = eq === -1 ? arg : arg.slice(0, eq);
75
+ const name = flag.slice(2);
76
+ if (!known.has(name)) throw new UsageError(`unknown flag ${flag}`);
77
+
78
+ if (booleans.has(name)) {
79
+ if (eq !== -1) throw new UsageError(`${flag} takes no value`);
80
+ options[name] = true;
81
+ continue;
82
+ }
83
+
84
+ let value;
85
+ if (eq !== -1) {
86
+ // `--flag=value` carries its value unambiguously, so a value that looks
87
+ // like a flag is legal here — this spelling is the escape hatch for one.
88
+ value = arg.slice(eq + 1);
89
+ } else {
90
+ const next = argv[i + 1];
91
+ // In the space form a `--`-prefixed token is the next flag, not a value.
92
+ if (next === undefined || next.startsWith('--')) {
93
+ throw new UsageError(`${flag} requires a value`);
94
+ }
95
+ value = next;
96
+ i += 1;
97
+ }
98
+ // An empty value is as valueless as none, in EITHER spelling. Both `--root=`
99
+ // and `--root ""` arrive when a shell expands an unset variable, and both
100
+ // would otherwise resolve to the current directory — answering about a repo
101
+ // nobody named.
102
+ // A flag may declare "" meaningful — preflight's empty `--concepts` selects
103
+ // store-health-only. Everything else treats it as no value at all.
104
+ if (value === '' && !allowEmpty.has(name)) throw new UsageError(`${flag} requires a value`);
105
+
106
+ if (repeatables.has(name)) (options[name] ??= []).push(value);
107
+ else options[name] = value;
108
+ }
109
+
110
+ return { options, positionals };
111
+ }
112
+
113
+ /**
114
+ * Run a command's main and own the epilogue: nothing it throws can exit 1.
115
+ *
116
+ * `main` returns an exit code, or throws. A UsageError reports itself with the
117
+ * usage line; anything else is an engine failure — the command did not finish,
118
+ * so it cannot have findings. Both exit 2.
119
+ *
120
+ * Returns the code rather than setting it, so the caller stays in charge of
121
+ * `process.exitCode`. Never calls `process.exit()`: that drops queued async
122
+ * stdout writes, truncating piped `--json` at the pipe buffer and yielding
123
+ * corrupt JSON with a clean exit code.
124
+ *
125
+ * @param {string} name the command's name, for its messages
126
+ * @param {(argv: string[]) => number | Promise<number>} main
127
+ * @param {object} opts
128
+ * @param {string} opts.usage the usage line printed on a UsageError
129
+ * @param {string[]} [opts.argv]
130
+ * @param {{ write: (s: string) => unknown }} [opts.stderr]
131
+ * @returns {Promise<number>} an exit code; 1 only if `main` returned it
132
+ */
133
+ export async function runCli(name, main, { usage, argv = process.argv.slice(2), stderr = process.stderr } = {}) {
134
+ try {
135
+ return await main(argv);
136
+ } catch (error) {
137
+ if (error instanceof UsageError) {
138
+ stderr.write(`${name}: ${error.message}\n${usage}\n`);
139
+ return EXIT_CODES.FAILURE;
140
+ }
141
+ // The command did not finish. It cannot have findings, so it must not wear
142
+ // the FINDINGS code — an agent would quarantine-and-continue past a check
143
+ // that never ran.
144
+ stderr.write(`${name}: internal failure — the command did not complete\n${error?.stack || error?.message || String(error)}\n`);
145
+ return EXIT_CODES.FAILURE;
146
+ }
147
+ }