@rungs/cli 0.3.1 → 0.4.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 (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2184 -478
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +18 -29
  62. package/src/engines2.ts +403 -20
  63. package/src/engines3.ts +111 -20
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +86 -27
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/src/substitute.ts CHANGED
@@ -1,4 +1,6 @@
1
- import { basename, resolve } from 'node:path';
1
+ import { readFileSync } from 'node:fs';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import type { Manifest } from './types.ts';
3
5
 
4
6
  export type Params = Record<string, Record<string, unknown>>;
@@ -42,6 +44,20 @@ function repoFacts(repoRoot?: string): Record<string, unknown> {
42
44
  return repoRoot ? { dirname: basename(resolve(repoRoot)) } : {};
43
45
  }
44
46
 
47
+ /**
48
+ * Facts about the Rungs artifact doing the rendering. Source execution and the
49
+ * published bundle live in `src/` and `dist/` respectively, so the package
50
+ * manifest is one directory above `import.meta.url` in both cases.
51
+ *
52
+ * This is deliberately not a module parameter. Parameters are copied into an
53
+ * install record and retained on upgrade; a CLI version must instead advance
54
+ * when the consumer explicitly invokes a newer artifact.
55
+ */
56
+ function rungsFacts(): Record<string, unknown> {
57
+ const packageJson = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
58
+ return { version: JSON.parse(readFileSync(packageJson, 'utf8')).version };
59
+ }
60
+
45
61
  /**
46
62
  * Defaults from every manifest, with explicit overrides applied on top.
47
63
  *
@@ -51,7 +67,7 @@ function repoFacts(repoRoot?: string): Record<string, unknown> {
51
67
  * the reason a missing root shows up as a wrong-looking file instead of a silently blank heading.
52
68
  */
53
69
  export function resolveParams(mods: Manifest[], overrides: Params = {}, repoRoot?: string): Params {
54
- const out: Params = { repo: repoFacts(repoRoot) };
70
+ const out: Params = {};
55
71
  for (const m of mods) {
56
72
  out[m.name] = {};
57
73
  for (const [k, spec] of Object.entries(m.params)) out[m.name][k] = spec.default;
@@ -68,6 +84,11 @@ export function resolveParams(mods: Manifest[], overrides: Params = {}, repoRoot
68
84
  out[mod] = { ...(out[mod] ?? {}), ...vals };
69
85
  }
70
86
 
87
+ // Facts win over manifests and overrides: both namespaces describe the
88
+ // execution context, not consumer configuration.
89
+ out.repo = repoFacts(repoRoot);
90
+ out.rungs = rungsFacts();
91
+
71
92
  // A default may reference another module's parameter, e.g. findings' register
72
93
  // living at `docs/{{backlog.root}}/FINDINGS.md`. One level only — a chain
73
94
  // would be a template language arriving through the back door.
@@ -95,14 +116,35 @@ export function markers(targetPath: string, module: string, version: string) {
95
116
  * mechanical and divergence a decision rather than an error.
96
117
  */
97
118
  export function mergeBlock(existing: string, fragment: string, module: string): string {
98
- const beginRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:begin ${module}(?:@[\\w.\\-]+)?\\s*(?:-->)?[ \\t]*$`, 'm');
99
- const endRe = new RegExp(`^[ \\t]*(?:<!--|#)\\s*rungs:end ${module}\\s*(?:-->)?[ \\t]*$`, 'm');
119
+ // Marker whitespace belongs to the marker line. `\\s*` also consumes newlines,
120
+ // which made the end match swallow inter-block separators and the file's final
121
+ // newline whenever an unchanged gate block was registered again (F-040). The
122
+ // match deliberately stops before CR/LF so those surrounding bytes stay outside
123
+ // the managed block.
124
+ const beginRe = new RegExp(
125
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:begin ${module}(?:@[\\w.\\-]+)?[ \\t]*(?:-->)?[ \\t]*$`,
126
+ 'm',
127
+ );
128
+ const endRe = new RegExp(
129
+ `^[ \\t]*(?:<!--|#)[ \\t]*rungs:end ${module}[ \\t]*(?:-->)?[ \\t]*$`,
130
+ 'm',
131
+ );
100
132
  const b = existing.match(beginRe);
101
133
  const e = existing.match(endRe);
102
134
  if (b && e && b.index !== undefined && e.index !== undefined && e.index > b.index) {
103
135
  const before = existing.slice(0, b.index);
104
136
  const after = existing.slice(e.index + e[0].length);
105
- return `${before}${fragment.trim()}${after}`;
137
+ const current = existing.slice(b.index, e.index + e[0].length);
138
+ const normalise = (value: string) => value.replace(/\r\n|\r|\n/g, '\n');
139
+ const replacement = fragment.trim();
140
+
141
+ // Registration builds fragments with LF on every platform. If the managed
142
+ // content is otherwise identical, preserve the original bytes—including a
143
+ // consumer checkout's CRLF convention—rather than manufacturing a diff.
144
+ if (normalise(current) === normalise(replacement)) return existing;
145
+
146
+ const newline = current.match(/\r\n|\r|\n/)?.[0] ?? existing.match(/\r\n|\r|\n/)?.[0] ?? '\n';
147
+ return `${before}${normalise(replacement).replace(/\n/g, newline)}${after}`;
106
148
  }
107
149
  const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
108
150
  return `${existing}${sep}${fragment.trim()}\n`;
package/src/text.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Normalize decoded repository text for semantic parsing only.
3
+ *
4
+ * Git may materialize tracked text as CRLF in a consumer even when the package
5
+ * source is LF. Parsers should not change their verdict with that checkout
6
+ * policy. Callers that compare ownership hashes or promise byte preservation
7
+ * must continue reading the original bytes instead.
8
+ */
9
+ export function semanticText(text: string): string {
10
+ return text.replace(/\r\n?/g, '\n');
11
+ }
package/src/types.ts CHANGED
@@ -89,9 +89,22 @@ export interface DetectSpec {
89
89
  }
90
90
 
91
91
  export interface Provenance {
92
- sources: string[];
93
- patterns: string[];
94
- incident: string;
92
+ /**
93
+ * Whether the module was **extracted** from a repo that already paid for it,
94
+ * or **designed** by somebody who thought it was a good idea.
95
+ *
96
+ * Absent means `extracted`: all fifteen bundled modules are, and normalising
97
+ * fifteen manifests to declare what they already said would be a migration
98
+ * rather than a distinction. `loadManifest` fills it in, so a reader may rely
99
+ * on it.
100
+ */
101
+ kind: 'extracted' | 'designed';
102
+ /** Required for `extracted`; forbidden for `designed` — see `loadManifest`. */
103
+ sources?: string[];
104
+ patterns?: string[];
105
+ incident?: string;
106
+ /** Required for `designed`: why it exists, in the first person. */
107
+ rationale?: string;
95
108
  }
96
109
 
97
110
  export interface Manifest {
@@ -0,0 +1,144 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extname, join } from 'node:path';
3
+ import { SaxesParser } from 'saxes';
4
+ import { parse as parseToml } from 'smol-toml';
5
+
6
+ /** A version location declared by a gate table. Pattern matching stays with the caller. */
7
+ export interface VersionSource {
8
+ file?: string;
9
+ path?: string;
10
+ xpath?: string;
11
+ }
12
+
13
+ /**
14
+ * A matched source either contributes one comparable value or explains why it cannot.
15
+ * There is deliberately no "not found" result: callers own globs and only call this
16
+ * reader after a concrete file matched.
17
+ */
18
+ export type VersionSourceResult =
19
+ | { ok: true; value: string }
20
+ | { ok: false; reason: string };
21
+
22
+ const invalidScalar = (where: string): VersionSourceResult => ({
23
+ ok: false,
24
+ reason: `${where} is not a non-empty string or finite number`,
25
+ });
26
+
27
+ function scalar(value: unknown, where: string): VersionSourceResult {
28
+ if (typeof value === 'string') {
29
+ const trimmed = value.trim();
30
+ return trimmed ? { ok: true, value: trimmed } : invalidScalar(where);
31
+ }
32
+ if (typeof value === 'number' && Number.isFinite(value)) {
33
+ return { ok: true, value: String(value) };
34
+ }
35
+ return invalidScalar(where);
36
+ }
37
+
38
+ function dottedValue(parsed: unknown, path: string): { found: true; value: unknown } | { found: false } {
39
+ if (!path.trim()) return { found: false };
40
+ let value: unknown = parsed;
41
+ for (const key of path.split('.')) {
42
+ if (!value || typeof value !== 'object' || !Object.hasOwn(value, key)) return { found: false };
43
+ value = (value as Record<string, unknown>)[key];
44
+ }
45
+ return { found: true, value };
46
+ }
47
+
48
+ function errorMessage(error: unknown): string {
49
+ return error instanceof Error ? error.message : String(error);
50
+ }
51
+
52
+ function xmlElement(text: string, rel: string, xpath: string): VersionSourceResult {
53
+ const match = /^\/\/([A-Za-z_][A-Za-z0-9_.:-]*)$/.exec(xpath);
54
+ if (!match) return { ok: false, reason: `unsupported XML xpath '${xpath}'; expected //Element` };
55
+
56
+ const element = match[1];
57
+ const values: { text: string; nested: boolean }[] = [];
58
+ const active: number[] = [];
59
+
60
+ try {
61
+ // Saxes validates a complete XML document and does not expand declarations
62
+ // from a DTD. Refuse the DTD outright so a version is always literal document
63
+ // evidence rather than an entity whose definition lives elsewhere.
64
+ const parser = new SaxesParser({ fragment: false, xmlns: false, fileName: rel });
65
+ parser.on('doctype', () => {
66
+ throw new Error('DOCTYPE declarations are not supported in version sources');
67
+ });
68
+ parser.on('opentag', (tag) => {
69
+ for (const index of active) values[index].nested = true;
70
+ if (tag.name === element) {
71
+ values.push({ text: '', nested: false });
72
+ active.push(values.length - 1);
73
+ }
74
+ });
75
+ const append = (value: string) => {
76
+ for (const index of active) values[index].text += value;
77
+ };
78
+ parser.on('text', append);
79
+ parser.on('cdata', append);
80
+ parser.on('closetag', (tag) => {
81
+ if (tag.name === element) active.pop();
82
+ });
83
+ parser.write(text).close();
84
+ } catch (error) {
85
+ return { ok: false, reason: `contains invalid XML: ${errorMessage(error)}` };
86
+ }
87
+
88
+ if (!values.length) return { ok: false, reason: `does not contain configured element '${xpath}'` };
89
+ if (values.length > 1) {
90
+ return { ok: false, reason: `configured element '${xpath}' matched ${values.length} values; expected one` };
91
+ }
92
+ if (values[0].nested) {
93
+ return { ok: false, reason: `configured element '${xpath}' contains nested XML; expected scalar text` };
94
+ }
95
+ return scalar(values[0].text, `configured element '${xpath}'`);
96
+ }
97
+
98
+ /**
99
+ * Read one already-matched version source.
100
+ *
101
+ * `path` means dotted JSON/TOML lookup, selected from the concrete filename.
102
+ * `xpath` intentionally supports only the release module's narrow `//Element`
103
+ * shape; pretending to implement general XPath would make a green result false.
104
+ */
105
+ export function readVersionSource(root: string, rel: string, source: VersionSource): VersionSourceResult {
106
+ let text: string;
107
+ try {
108
+ text = readFileSync(join(root, rel), 'utf8');
109
+ } catch (error) {
110
+ return { ok: false, reason: `could not read version source: ${errorMessage(error)}` };
111
+ }
112
+
113
+ if (source.path && source.xpath) {
114
+ return { ok: false, reason: 'declares both `path` and `xpath`; choose one version lookup' };
115
+ }
116
+
117
+ if (source.path) {
118
+ const extension = extname(rel).toLowerCase();
119
+ let parsed: unknown;
120
+ try {
121
+ if (extension === '.json') parsed = JSON.parse(text);
122
+ else if (extension === '.toml') parsed = parseToml(text);
123
+ else {
124
+ return {
125
+ ok: false,
126
+ reason: `cannot read dotted path '${source.path}' from '${extension || '(no extension)'}'; use JSON or TOML`,
127
+ };
128
+ }
129
+ } catch (error) {
130
+ const format = extension === '.toml' ? 'TOML' : 'JSON';
131
+ return { ok: false, reason: `contains invalid ${format}: ${errorMessage(error)}` };
132
+ }
133
+
134
+ const found = dottedValue(parsed, source.path);
135
+ if (!found.found) return { ok: false, reason: `does not contain configured path '${source.path}'` };
136
+ return scalar(found.value, `configured path '${source.path}'`);
137
+ }
138
+
139
+ if (source.xpath) {
140
+ return xmlElement(text, rel, source.xpath);
141
+ }
142
+
143
+ return { ok: false, reason: 'declares neither `path` nor `xpath` for its version value' };
144
+ }