simt-dev 0.2.0__py3-none-any.whl
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.
- python/simt_dev/__init__.py +32 -0
- python/simt_dev/cli.py +225 -0
- simt_dev/__init__.py +32 -0
- simt_dev/cli.py +225 -0
- simt_dev/vendored/CHANGELOG.md +166 -0
- simt_dev/vendored/bin/simt-dev.mjs +28 -0
- simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
- simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
- simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
- simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
- simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
- simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
- simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
- simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
- simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
- simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
- simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
- simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
- simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
- simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
- simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
- simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
- simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
- simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
- simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
- simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
- simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
- simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
- simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
- simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
- simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
- simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
- simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
- simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
- simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
- simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
- simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
- simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
- simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
- simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
- simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
- simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
- simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
- simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
- simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
- simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
- simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
- simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
- simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
- simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
- simt_dev/vendored/docs/decisions/README.md +530 -0
- simt_dev/vendored/graph/skills.graph.json +1622 -0
- simt_dev/vendored/package.json +43 -0
- simt_dev/vendored/rules/core.md +76 -0
- simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
- simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
- simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
- simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
- simt_dev/vendored/rules/stacks/android.md +63 -0
- simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
- simt_dev/vendored/rules/stacks/connectiq.md +62 -0
- simt_dev/vendored/rules/stacks/container.md +31 -0
- simt_dev/vendored/rules/stacks/jvm.md +54 -0
- simt_dev/vendored/rules/stacks/node.md +45 -0
- simt_dev/vendored/rules/stacks/python.md +48 -0
- simt_dev/vendored/rules/stacks/react-native.md +58 -0
- simt_dev/vendored/rules/stacks/react-web.md +61 -0
- simt_dev/vendored/rules/stacks/typescript.md +21 -0
- simt_dev/vendored/rules/stacks/uv.md +32 -0
- simt_dev/vendored/src/argv.mjs +116 -0
- simt_dev/vendored/src/ci/dependabot.mjs +268 -0
- simt_dev/vendored/src/ci/workflow.mjs +360 -0
- simt_dev/vendored/src/commands/ci.mjs +285 -0
- simt_dev/vendored/src/commands/compare.mjs +195 -0
- simt_dev/vendored/src/commands/graph.mjs +73 -0
- simt_dev/vendored/src/commands/help.mjs +108 -0
- simt_dev/vendored/src/commands/hooks.mjs +471 -0
- simt_dev/vendored/src/commands/index.mjs +241 -0
- simt_dev/vendored/src/commands/init.mjs +632 -0
- simt_dev/vendored/src/commands/mcp.mjs +63 -0
- simt_dev/vendored/src/commands/rules.mjs +374 -0
- simt_dev/vendored/src/commands/skills.mjs +647 -0
- simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
- simt_dev/vendored/src/commands/sync.mjs +331 -0
- simt_dev/vendored/src/commands/upgrade.mjs +202 -0
- simt_dev/vendored/src/compare/discover.mjs +212 -0
- simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
- simt_dev/vendored/src/compare/row.mjs +299 -0
- simt_dev/vendored/src/decisions/index.mjs +536 -0
- simt_dev/vendored/src/graph/load.mjs +541 -0
- simt_dev/vendored/src/hooks/capability.mjs +129 -0
- simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
- simt_dev/vendored/src/hooks/gates.mjs +144 -0
- simt_dev/vendored/src/hooks/resolve.mjs +194 -0
- simt_dev/vendored/src/hooks/shim.mjs +140 -0
- simt_dev/vendored/src/hooks/where.mjs +118 -0
- simt_dev/vendored/src/identity.mjs +218 -0
- simt_dev/vendored/src/locate.mjs +75 -0
- simt_dev/vendored/src/mcp/server.mjs +167 -0
- simt_dev/vendored/src/mcp/tools.mjs +410 -0
- simt_dev/vendored/src/name.mjs +95 -0
- simt_dev/vendored/src/output/index.mjs +96 -0
- simt_dev/vendored/src/projection/baselines.mjs +209 -0
- simt_dev/vendored/src/projection/conflicts.mjs +205 -0
- simt_dev/vendored/src/projection/manifest.mjs +478 -0
- simt_dev/vendored/src/projection/markers.mjs +349 -0
- simt_dev/vendored/src/projection/mdc.mjs +327 -0
- simt_dev/vendored/src/projection/project.mjs +504 -0
- simt_dev/vendored/src/projection/rules.mjs +640 -0
- simt_dev/vendored/src/projection/surfaces.mjs +398 -0
- simt_dev/vendored/src/projection/targets.mjs +221 -0
- simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
- simt_dev/vendored/src/resolve/near.mjs +61 -0
- simt_dev/vendored/src/resolve/resolve.mjs +457 -0
- simt_dev/vendored/src/resolve/source.mjs +105 -0
- simt_dev/vendored/src/toolchain.mjs +202 -0
- simt_dev/vendored/src/tsconfig.mjs +314 -0
- simt_dev/vendored/src/upgrade/plan.mjs +203 -0
- simt_dev/vendored/src/workspace.mjs +289 -0
- simt_dev-0.2.0.dist-info/METADATA +150 -0
- simt_dev-0.2.0.dist-info/RECORD +138 -0
- simt_dev-0.2.0.dist-info/WHEEL +4 -0
- simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
- simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// simt-dev mcp - serve the tool surface over MCP on stdio.
|
|
2
|
+
//
|
|
3
|
+
// Optional by design (plan 0002 section 5): it adds a transport, not a capability, and
|
|
4
|
+
// the CLI stays the primary interface because it works in CI, in a hook and in a bare
|
|
5
|
+
// shell. What this command must NOT become is a second implementation - it wires the same
|
|
6
|
+
// modules the commands use into a line protocol and does nothing else.
|
|
7
|
+
//
|
|
8
|
+
// `--list` prints the surface and exits, without reading stdin. An editor that has
|
|
9
|
+
// misconfigured the command needs to be able to ask what it exposes without hanging, and
|
|
10
|
+
// a command that only speaks when spoken to cannot answer that.
|
|
11
|
+
|
|
12
|
+
import { readFile } from 'node:fs/promises';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
17
|
+
import { TOOLS } from '../mcp/tools.mjs';
|
|
18
|
+
import { PROTOCOL_VERSION, serve } from '../mcp/server.mjs';
|
|
19
|
+
import { CLI } from '../name.mjs';
|
|
20
|
+
|
|
21
|
+
export async function mcpCommand(positional, flags) {
|
|
22
|
+
const { version } = await packageVersion();
|
|
23
|
+
|
|
24
|
+
if (flags.list === true) {
|
|
25
|
+
if (isJson(flags)) {
|
|
26
|
+
emit(`${JSON.stringify({ ok: true, protocolVersion: PROTOCOL_VERSION, version, tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) }, null, 2)}\n`);
|
|
27
|
+
return EXIT.OK;
|
|
28
|
+
}
|
|
29
|
+
emit(
|
|
30
|
+
[
|
|
31
|
+
`simt-dev ${version} - MCP surface over stdio (protocol ${PROTOCOL_VERSION})`,
|
|
32
|
+
'',
|
|
33
|
+
`${TOOLS.length} read-only tools:`,
|
|
34
|
+
...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
|
|
35
|
+
'',
|
|
36
|
+
'No tool here writes. init, sync, hooks install, skills install and upgrade stay in the',
|
|
37
|
+
'CLI, because each is meant to be read as a diff by a human before it touches anything.',
|
|
38
|
+
'',
|
|
39
|
+
`serve them with: ${positional[0] ?? `${CLI} mcp`}`,
|
|
40
|
+
].join('\n'),
|
|
41
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
42
|
+
);
|
|
43
|
+
return EXIT.OK;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await serve(TOOLS, { version });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return fail(`mcp transport failed: ${error.message}`, EXIT.FAIL, `run: ${CLI} mcp --list to check the surface loads`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function packageVersion() {
|
|
54
|
+
try {
|
|
55
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
56
|
+
const pkg = JSON.parse(await readFile(join(here, '..', '..', 'package.json'), 'utf8'));
|
|
57
|
+
return { version: pkg.version };
|
|
58
|
+
} catch {
|
|
59
|
+
// A missing version must not stop the server: the client already knows which
|
|
60
|
+
// build it launched, and a hard failure here would look like a protocol bug.
|
|
61
|
+
return { version: 'unknown' };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// `simt-dev rules` - the rule catalogue and its provenance.
|
|
2
|
+
//
|
|
3
|
+
// A rule is only meaningful if you can answer "why is this here?". Three
|
|
4
|
+
// commands, mirroring the skill surface deliberately: `list` is the catalogue,
|
|
5
|
+
// `show` is one body, `why` is the decision chain behind it.
|
|
6
|
+
//
|
|
7
|
+
// The load-bearing one is `why`. Every rule body cites the ADR that settled it,
|
|
8
|
+
// so the citation can be resolved: title, status, and - the part that matters -
|
|
9
|
+
// whether the record is still in force. ADR-011 was accepted and reversed by
|
|
10
|
+
// ADR-012 the same day; printing 011's ruling without that would hand a reader a
|
|
11
|
+
// decision nobody is following.
|
|
12
|
+
//
|
|
13
|
+
// A rule that cites nothing is a finding, not an empty answer. It is reported as
|
|
14
|
+
// one, because "this rule has no decision behind it" is exactly the fact a
|
|
15
|
+
// reviewer needs and exactly the fact an empty output would hide.
|
|
16
|
+
|
|
17
|
+
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
18
|
+
import { loadRules, select } from '../projection/project.mjs';
|
|
19
|
+
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
20
|
+
import { byId, citations, loadDecisions, redactEstate } from '../decisions/index.mjs';
|
|
21
|
+
import { nearMatches } from '../resolve/near.mjs';
|
|
22
|
+
import { CLI } from '../name.mjs';
|
|
23
|
+
|
|
24
|
+
const kb = (bytes) => `${(bytes / 1000).toFixed(1)}k`;
|
|
25
|
+
|
|
26
|
+
/** `simt-applies` is the string "always" for core and an array for profiles. */
|
|
27
|
+
function appliesLabel(rule) {
|
|
28
|
+
if (rule.applies === 'always') return 'always';
|
|
29
|
+
if (Array.isArray(rule.applies)) return rule.applies.join(',') || '-';
|
|
30
|
+
return '-';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Sorted for run-to-run diffability: the catalogue is read by agents, not just humans. */
|
|
34
|
+
function sorted(rules) {
|
|
35
|
+
return [...rules].sort((a, b) => a.id.localeCompare(b.id));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function load(root) {
|
|
39
|
+
const rules = await loadRules(root ?? null);
|
|
40
|
+
const broken = rules.filter((r) => r.error);
|
|
41
|
+
return { rules, broken };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- list ---------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export async function listCommand(args, flags) {
|
|
47
|
+
const root = flags.root ?? null;
|
|
48
|
+
const { rules, broken } = await load(root);
|
|
49
|
+
|
|
50
|
+
// With --root the question becomes "which of these apply to me", which is a
|
|
51
|
+
// different answer from the catalogue and the one an agent usually wants.
|
|
52
|
+
const selection = root ? await selectionFor(rules, root) : null;
|
|
53
|
+
|
|
54
|
+
if (isJson(flags)) {
|
|
55
|
+
emit(
|
|
56
|
+
`${JSON.stringify(
|
|
57
|
+
{
|
|
58
|
+
ok: broken.length === 0,
|
|
59
|
+
command: 'rules list',
|
|
60
|
+
root,
|
|
61
|
+
rules: sorted(rules).map((r) => ({
|
|
62
|
+
id: r.id,
|
|
63
|
+
title: r.title,
|
|
64
|
+
origin: r.origin,
|
|
65
|
+
source: r.source,
|
|
66
|
+
applies: r.applies,
|
|
67
|
+
fallback: r.fallback,
|
|
68
|
+
bytes: r.bytes,
|
|
69
|
+
error: r.error,
|
|
70
|
+
adrs: citations(r.body),
|
|
71
|
+
selected: selection ? selection.selected.has(r.id) : null,
|
|
72
|
+
})),
|
|
73
|
+
notes: selection?.notes ?? [],
|
|
74
|
+
},
|
|
75
|
+
null,
|
|
76
|
+
2,
|
|
77
|
+
)}\n`,
|
|
78
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
79
|
+
);
|
|
80
|
+
return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const total = rules.reduce((n, r) => n + r.bytes, 0);
|
|
84
|
+
const local = rules.filter((r) => r.origin === 'local').length;
|
|
85
|
+
const rows = sorted(rules);
|
|
86
|
+
|
|
87
|
+
// Column widths are measured from the data rather than hardcoded. `simt-applies` is a
|
|
88
|
+
// list of signals whose length is the whole point of the column, so a fixed pad
|
|
89
|
+
// silently pushed the title out of alignment for exactly the profiles a reader
|
|
90
|
+
// most wants to compare. Measured widths are still deterministic for a given
|
|
91
|
+
// rule set, which is what run-to-run diffability needs.
|
|
92
|
+
const widths = {
|
|
93
|
+
id: Math.max(2, ...rows.map((r) => r.id.length)),
|
|
94
|
+
bytes: Math.max(5, ...rows.map((r) => kb(r.bytes).length)),
|
|
95
|
+
origin: Math.max(6, ...rows.map((r) => r.origin.length)),
|
|
96
|
+
applies: Math.max(7, ...rows.map((r) => appliesLabel(r).length)),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// The selection marker stays welded to the id: it is a mark on that row, not a
|
|
100
|
+
// column of its own, and `*connectiq` is greppable where `* connectiq` is not.
|
|
101
|
+
const render = (mark, id, bytes, origin, applies, title) =>
|
|
102
|
+
`${mark}${id.padEnd(widths.id)} ${bytes.padStart(widths.bytes)} ${origin.padEnd(widths.origin)} ${applies.padEnd(widths.applies)} ${title}`;
|
|
103
|
+
|
|
104
|
+
const lines = [
|
|
105
|
+
`${rules.length} rules, ${kb(total)}${local > 0 ? `, ${local} local` : ''}${selection ? '' : ' (pass --root to see which apply to a repo)'}`,
|
|
106
|
+
'',
|
|
107
|
+
render(' ', 'id', 'bytes', 'origin', 'applies', 'title'),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const rule of rows) {
|
|
111
|
+
const mark = selection && selection.selected.has(rule.id) ? '*' : ' ';
|
|
112
|
+
lines.push(
|
|
113
|
+
render(mark, rule.id, kb(rule.bytes), rule.origin, appliesLabel(rule), rule.title ?? '(no title)'),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const rule of broken) {
|
|
118
|
+
lines.push('', `BROKEN ${rule.source}: ${rule.error}`);
|
|
119
|
+
}
|
|
120
|
+
if (selection) {
|
|
121
|
+
lines.push('', `* selected for ${root}`);
|
|
122
|
+
for (const note of selection.notes) lines.push(` note ${note}`);
|
|
123
|
+
}
|
|
124
|
+
if (root) {
|
|
125
|
+
const locals = rules.filter((r) => r.origin === 'local');
|
|
126
|
+
if (locals.length > 0) lines.push('', `local profiles come from ${locals.map((r) => r.source).join(', ')}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
130
|
+
return broken.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function selectionFor(rules, root) {
|
|
134
|
+
const { stack, facts } = await fingerprint(root);
|
|
135
|
+
const { selected, notes } = select(rules, stack, facts);
|
|
136
|
+
return { selected: new Set(selected.map((r) => r.id)), notes };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- show ---------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
export async function showCommand(args, flags) {
|
|
142
|
+
const id = args[0];
|
|
143
|
+
if (!id) return fail('rules show needs a rule id', EXIT.USAGE, `usage: ${CLI} rules show <id>`);
|
|
144
|
+
|
|
145
|
+
const { rules, broken } = await load(flags.root ?? null);
|
|
146
|
+
const rule = rules.find((r) => r.id === id);
|
|
147
|
+
if (!rule) return unknown(id, rules);
|
|
148
|
+
|
|
149
|
+
const adrs = citations(rule.body);
|
|
150
|
+
if (isJson(flags)) {
|
|
151
|
+
emit(`${JSON.stringify({ ok: !rule.error, ...rule, adrs }, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
|
|
152
|
+
return rule.error ? EXIT.FAIL : EXIT.OK;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
emit(
|
|
156
|
+
[
|
|
157
|
+
rule.id,
|
|
158
|
+
rule.title ?? '(no title)',
|
|
159
|
+
'',
|
|
160
|
+
`applies ${appliesLabel(rule)}${rule.fallback ? ' (fallback)' : ''}`,
|
|
161
|
+
`origin ${rule.origin}`,
|
|
162
|
+
`source ${rule.source}`,
|
|
163
|
+
`bytes ${rule.bytes}`,
|
|
164
|
+
`adrs ${adrs.length > 0 ? adrs.join(', ') : 'NONE - no decision is cited by this rule'}`,
|
|
165
|
+
'',
|
|
166
|
+
rule.body,
|
|
167
|
+
].join('\n'),
|
|
168
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
169
|
+
);
|
|
170
|
+
return rule.error ? EXIT.FAIL : EXIT.OK;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- why ----------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Two directions, because both questions get asked:
|
|
177
|
+
* `rules why <rule-id>` -> which decisions settled this rule, and are they in force
|
|
178
|
+
* `rules why <ADR-010>` -> which rules rest on this decision
|
|
179
|
+
*/
|
|
180
|
+
export async function whyCommand(args, flags) {
|
|
181
|
+
const what = args[0];
|
|
182
|
+
if (!what) return fail('rules why needs a rule id or an ADR id', EXIT.USAGE, `usage: ${CLI} rules why <rule-id|ADR-NNN>`);
|
|
183
|
+
|
|
184
|
+
const { rules, broken } = await load(flags.root ?? null);
|
|
185
|
+
const { ok, decisions, errors } = await loadDecisions();
|
|
186
|
+
if (!ok) return fail(errors[0], EXIT.CONFIG, 'the decision records must be readable for provenance to work');
|
|
187
|
+
|
|
188
|
+
return what.match(/^ADR-\d{3}$/i)
|
|
189
|
+
? adrDirection(what.toUpperCase(), rules, decisions, flags)
|
|
190
|
+
: ruleDirection(what, rules, decisions, flags, broken);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function ruleDirection(id, rules, decisions, flags, broken) {
|
|
194
|
+
const rule = rules.find((r) => r.id === id);
|
|
195
|
+
if (!rule) return unknown(id, rules);
|
|
196
|
+
|
|
197
|
+
const cited = citations(rule.body);
|
|
198
|
+
const index = byId(decisions);
|
|
199
|
+
|
|
200
|
+
if (isJson(flags)) {
|
|
201
|
+
emit(
|
|
202
|
+
`${JSON.stringify(
|
|
203
|
+
{
|
|
204
|
+
ok: true,
|
|
205
|
+
command: 'rules why',
|
|
206
|
+
rule: rule.id,
|
|
207
|
+
source: rule.source,
|
|
208
|
+
origin: rule.origin,
|
|
209
|
+
adrs: cited.map((c) => index.get(c) ?? { id: c, missing: true }),
|
|
210
|
+
uncited: cited.length === 0,
|
|
211
|
+
},
|
|
212
|
+
null,
|
|
213
|
+
2,
|
|
214
|
+
)}\n`,
|
|
215
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
216
|
+
);
|
|
217
|
+
return EXIT.OK;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const lines = [`${rule.id} - ${rule.title ?? '(no title)'}`, '', `source ${rule.source} (${rule.origin})`];
|
|
221
|
+
|
|
222
|
+
if (cited.length === 0) {
|
|
223
|
+
lines.push(
|
|
224
|
+
'',
|
|
225
|
+
'provenance NONE',
|
|
226
|
+
' this rule cites no decision, so nothing settles it and nothing can overturn it.',
|
|
227
|
+
' Either cite the ADR that produced it, or accept that it is this repo\'s own policy.',
|
|
228
|
+
);
|
|
229
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
230
|
+
return EXIT.OK;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
lines.push('', `settled by ${cited.length} decision${cited.length === 1 ? '' : 's'}`);
|
|
234
|
+
for (const adr of cited) {
|
|
235
|
+
const d = index.get(adr);
|
|
236
|
+
if (!d) {
|
|
237
|
+
lines.push(` ${adr} MISSING - the rule cites it, but this installation has no record of it`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
lines.push(` ${d.id} ${d.title}`);
|
|
241
|
+
lines.push(` ${d.status}`);
|
|
242
|
+
if (d.supersededBy) {
|
|
243
|
+
// Never print a reversed ruling as if it were current.
|
|
244
|
+
lines.push(` SUPERSEDED by ${d.supersededBy} - read that record instead`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// The reverse edge is what makes this useful for maintaining the set: which
|
|
249
|
+
// rules would a change to one decision touch?
|
|
250
|
+
const others = rules.filter((r) => r.id !== rule.id && citations(r.body).some((c) => cited.includes(c)));
|
|
251
|
+
if (others.length > 0) {
|
|
252
|
+
lines.push('', `shares a decision with ${others.map((r) => r.id).join(', ')}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
256
|
+
return EXIT.OK;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function adrDirection(adr, rules, decisions, flags) {
|
|
260
|
+
const d = decisions.find((x) => x.id === adr);
|
|
261
|
+
if (!d) {
|
|
262
|
+
// Was 'list them with: ls docs/decisions/' - a path relative to simt-dev's own
|
|
263
|
+
// tree, which resolves to nothing in the repo the reader is standing in. The
|
|
264
|
+
// remedy must be a command they can run.
|
|
265
|
+
return fail(`no decision record ${adr}`, EXIT.USAGE, `list the known decisions with: ${CLI} rules list`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const restates = rules.filter((r) => citations(r.body).includes(d.id));
|
|
269
|
+
|
|
270
|
+
if (isJson(flags)) {
|
|
271
|
+
// The machine payload answers the same question as the text one. A caller reading
|
|
272
|
+
// `adr.summary.ruled` off a superseded record would get the reversal, so the
|
|
273
|
+
// effective ruling is carried alongside rather than instead: the record asked about
|
|
274
|
+
// is still named, and what is in force is named separately.
|
|
275
|
+
const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
|
|
276
|
+
emit(
|
|
277
|
+
`${JSON.stringify(
|
|
278
|
+
{
|
|
279
|
+
ok: true,
|
|
280
|
+
command: 'rules why',
|
|
281
|
+
adr: d,
|
|
282
|
+
supersededBy: replacement ? { id: replacement.id, title: replacement.title } : null,
|
|
283
|
+
inForce: replacement
|
|
284
|
+
? { id: replacement.id, status: replacement.status, summary: replacement.summary }
|
|
285
|
+
: { id: d.id, status: d.status, summary: d.summary },
|
|
286
|
+
rules: restates.map((r) => r.id),
|
|
287
|
+
},
|
|
288
|
+
null,
|
|
289
|
+
2,
|
|
290
|
+
)}\n`,
|
|
291
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
292
|
+
);
|
|
293
|
+
return EXIT.OK;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const lines = [
|
|
297
|
+
`${d.id} - ${d.title}`,
|
|
298
|
+
'',
|
|
299
|
+
`status ${d.status}`,
|
|
300
|
+
`restates ${restates.length > 0 ? restates.map((r) => r.id).join(', ') : '(no rule cites it)'}`,
|
|
301
|
+
];
|
|
302
|
+
if (d.supersededBy) lines.push(`SUPERSEDED by ${d.supersededBy} - this record is retained as evidence, not as policy.`);
|
|
303
|
+
if (d.supersedes.length > 0) lines.push(`supersedes ${d.supersedes.join(', ')}`);
|
|
304
|
+
// A partial supersession is named here as well as on the ruling it moved, because the
|
|
305
|
+
// two print different sets: this prints every ruling the record lost, and the
|
|
306
|
+
// annotation prints the ones inside the excerpt below. A ruling past the excerpt
|
|
307
|
+
// exists in the estate but not in this output, and the status line above is the only
|
|
308
|
+
// other place it appears - which is the sentence the parser used to truncate.
|
|
309
|
+
if (d.partial.length > 0) {
|
|
310
|
+
lines.push(
|
|
311
|
+
`partial ${d.partial
|
|
312
|
+
.map((p) => `ruling ${p.ruling ?? '(not numbered)'} ${p.kind} by ${p.by ?? 'an unnamed record'}`)
|
|
313
|
+
.join('; ')}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// A superseded record must not print its own ruling. ADR-011 ruled that Windows is
|
|
318
|
+
// supported; ADR-012 reversed it the same day. Printing "Windows is a supported v1
|
|
319
|
+
// platform" under a SUPERSEDED banner is a reversed instruction with a warning
|
|
320
|
+
// attached, and an agent that skims the ruling and not the banner acts on the
|
|
321
|
+
// reversal. This is the failure the decision index's own header names: a provenance
|
|
322
|
+
// feature that proves provenance but not currency.
|
|
323
|
+
const replacement = d.supersededBy ? decisions.find((x) => x.id === d.supersededBy) : null;
|
|
324
|
+
const effective = replacement ?? d;
|
|
325
|
+
|
|
326
|
+
if (replacement) {
|
|
327
|
+
lines.push(
|
|
328
|
+
'',
|
|
329
|
+
`instead ${replacement.id} - ${replacement.title}`,
|
|
330
|
+
` status ${replacement.status}`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// The reason the ruling is printed at all, and not a pointer to this command: the
|
|
335
|
+
// reader arrived here from a `(ADR-NNN)` citation in a rule, and the only question
|
|
336
|
+
// that citation raises is "why am I being told this?". A title and a status answer
|
|
337
|
+
// "does it exist and is it current", which is the smaller half.
|
|
338
|
+
//
|
|
339
|
+
// `## Context` is deliberately absent. It is a survey of what particular
|
|
340
|
+
// repositories used to say - true, citable, and following none of it from a repo
|
|
341
|
+
// that owns none of those files.
|
|
342
|
+
if (effective.summary.ruled.length > 0) {
|
|
343
|
+
lines.push('', replacement ? 'ruled now' : 'ruled');
|
|
344
|
+
for (const r of effective.summary.ruled) lines.push(` - ${redactEstate(r)}`);
|
|
345
|
+
}
|
|
346
|
+
if (effective.summary.rejected.length > 0) {
|
|
347
|
+
// The rejected alternatives are the part a reader cannot reconstruct. A ruling is
|
|
348
|
+
// only legible against the option it refused. Estate names in that option are
|
|
349
|
+
// redacted: they name files the reader does not have.
|
|
350
|
+
lines.push('', 'rejected');
|
|
351
|
+
for (const r of effective.summary.rejected) lines.push(` - ${redactEstate(r)}`);
|
|
352
|
+
}
|
|
353
|
+
if (effective.summary.writtenAgainst.length > 0) {
|
|
354
|
+
lines.push(
|
|
355
|
+
'',
|
|
356
|
+
'written against repositories this record surveyed',
|
|
357
|
+
' the ruling above is the part that generalises; those files are not yours.',
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
362
|
+
// A superseded record is not a failure; it is the supersession rule working.
|
|
363
|
+
// The caller asked a question and got a true answer.
|
|
364
|
+
return EXIT.OK;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function unknown(id, rules) {
|
|
368
|
+
const near = nearMatches(id, rules.map((r) => r.id));
|
|
369
|
+
return fail(
|
|
370
|
+
`no rule with id "${id}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
|
|
371
|
+
EXIT.USAGE,
|
|
372
|
+
`run: ${CLI} rules list`,
|
|
373
|
+
);
|
|
374
|
+
}
|