simt-dev 0.2.2 → 0.3.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/CHANGELOG.md +100 -0
- package/README.md +129 -75
- package/bin/simt-dev.mjs +21 -2
- package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +1 -1
- package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
- package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
- package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
- package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
- package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
- package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
- package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
- package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
- package/docs/decisions/ADR-083-a-budget-is-met-by-cutting-the-kernel-and-hard-is-the-floor.md +153 -0
- package/docs/decisions/ADR-084-a-workspace-carries-its-repeated-rules-once.md +100 -0
- package/docs/decisions/ADR-085-the-region-ends-with-the-names-it-kept-out.md +72 -0
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +69 -0
- package/docs/decisions/ADR-087-a-run-is-evidence-for-one-tree.md +67 -0
- package/docs/decisions/ADR-088-a-marked-line-is-enforced-by-a-checker.md +59 -0
- package/docs/decisions/ADR-089-the-local-index-is-a-cache-of-git-blobs.md +52 -0
- package/docs/decisions/ADR-090-an-optional-adapter-is-off-until-named.md +55 -0
- package/docs/decisions/ADR-091-the-map-ranks-files-a-task-mentions.md +60 -0
- package/docs/decisions/ADR-092-tests-are-imports-conventions-and-coverage.md +60 -0
- package/docs/decisions/ADR-093-why-names-what-governs-a-file.md +58 -0
- package/docs/decisions/ADR-094-a-change-runs-the-tests-that-import-it.md +44 -0
- package/docs/decisions/ADR-095-gate-output-keeps-the-failures.md +31 -0
- package/docs/decisions/ADR-096-impact-names-who-a-change-reaches.md +38 -0
- package/package.json +2 -1
- package/rules/core.md +1 -1
- package/src/agenthooks/decide.mjs +123 -0
- package/src/agenthooks/policy.mjs +78 -0
- package/src/agenthooks/render.mjs +240 -0
- package/src/argv.mjs +54 -1
- package/src/bench/ablate.mjs +630 -0
- package/src/bench/adapters.json +149 -0
- package/src/bench/grade.mjs +229 -0
- package/src/bench/mine.mjs +811 -0
- package/src/bench/probes.mjs +1350 -0
- package/src/bench/report.mjs +899 -0
- package/src/bench/run.mjs +1002 -0
- package/src/ci/workflow.mjs +11 -1
- package/src/commands/agenthook.mjs +282 -0
- package/src/commands/bench.mjs +371 -0
- package/src/commands/evidence.mjs +96 -0
- package/src/commands/gate.mjs +165 -0
- package/src/commands/harness.mjs +156 -5
- package/src/commands/help.mjs +89 -1
- package/src/commands/hooks.mjs +45 -5
- package/src/commands/impact.mjs +205 -0
- package/src/commands/index.mjs +60 -1
- package/src/commands/map.mjs +91 -0
- package/src/commands/plan.mjs +99 -0
- package/src/commands/repoindex.mjs +52 -0
- package/src/commands/shared.mjs +398 -0
- package/src/commands/sync.mjs +72 -3
- package/src/commands/tests.mjs +192 -0
- package/src/commands/tokens.mjs +61 -1
- package/src/commands/why.mjs +177 -0
- package/src/decisions/index.mjs +23 -5
- package/src/graph/spec.mjs +1 -1
- package/src/hooks/dispatcher.mjs +15 -0
- package/src/hooks/shim.mjs +31 -0
- package/src/index/compact.mjs +228 -0
- package/src/index/extract.mjs +331 -0
- package/src/index/gate.mjs +101 -0
- package/src/index/govern.mjs +222 -0
- package/src/index/impact.mjs +186 -0
- package/src/index/map.mjs +393 -0
- package/src/index/persist.mjs +172 -0
- package/src/index/store.mjs +301 -0
- package/src/index/tests.mjs +294 -0
- package/src/kernel/budget.mjs +100 -0
- package/src/kernel/card.mjs +254 -0
- package/src/kernel/checks.mjs +378 -0
- package/src/kernel/inferability.mjs +188 -0
- package/src/ledger/index.mjs +112 -0
- package/src/mcp/resources.mjs +157 -0
- package/src/mcp/server.mjs +37 -6
- package/src/mcp/tools.mjs +84 -0
- package/src/measure/context.mjs +184 -11
- package/src/measure/loaderView.mjs +435 -0
- package/src/measure/session.mjs +267 -0
- package/src/plan/index.mjs +650 -0
- package/src/plan/template.mjs +331 -0
- package/src/projection/disclosure.mjs +92 -0
- package/src/projection/loaders.mjs +689 -0
- package/src/projection/lock.mjs +93 -0
- package/src/projection/manifest.mjs +78 -3
- package/src/projection/markers.mjs +56 -17
- package/src/projection/project.mjs +24 -17
- package/src/projection/rules.mjs +452 -43
- package/src/projection/shared.mjs +428 -0
- package/src/projection/surfaces.mjs +22 -8
- package/src/projection/targets.mjs +2 -1
- package/src/toolwords.mjs +71 -0
- package/templates/plan/tracker.json +36 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ADR-091 — The map ranks the files a task mentions, and the files next to them
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R22 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/map.mjs`, `src/commands/map.mjs`, `src/commands/bench.mjs`,
|
|
6
|
+
`test/map.test.mjs`
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
A task names a behaviour. The files that behaviour lives in are a small part of
|
|
11
|
+
the tree, and an agent that reads the tree in path order spends its budget on
|
|
12
|
+
files the task never touches. The index now records imports. What it did not
|
|
13
|
+
do is say which of those files to read first.
|
|
14
|
+
|
|
15
|
+
The mined tasks store a gold patch outside the worktree. The published task
|
|
16
|
+
record counts the files and the lines. It does not list the paths. Recall has
|
|
17
|
+
to be read from the patch, and a machine without that patch cannot invent the
|
|
18
|
+
denominator.
|
|
19
|
+
|
|
20
|
+
## Rulings
|
|
21
|
+
|
|
22
|
+
1. **The map is PageRank over import and call edges, personalised to the task.**
|
|
23
|
+
Twenty iterations, damping 0.85. An edge counts in both directions, once.
|
|
24
|
+
A file the task mentions sorts ahead of a file it does not: on this graph
|
|
25
|
+
the file every module imports otherwise occupies the top 10. A term that
|
|
26
|
+
labels too many paths is ignored. A token that contains a digit and names
|
|
27
|
+
one path counts more the more often the task says it, so a citation does
|
|
28
|
+
not rank with the subject. A file that already matched, in a directory of
|
|
29
|
+
at most eight files, is lifted toward its neighbour. Files the task does
|
|
30
|
+
not mention, and that are not one edge away from a file it does, are left
|
|
31
|
+
out. With no task text the whole graph is ranked. Equal ranks break by
|
|
32
|
+
path, ascending. The same graph and the same text return the same order.
|
|
33
|
+
|
|
34
|
+
2. **The rendering fits a budget, at one of three levels.** `outline` is paths.
|
|
35
|
+
`names` adds the exported symbols. `signatures` adds the line they were
|
|
36
|
+
declared on. The default budget is 2048 bytes. Zero means no ceiling. The
|
|
37
|
+
first file is kept when it alone is over the budget, so a map of a real
|
|
38
|
+
ranking is not an empty page.
|
|
39
|
+
|
|
40
|
+
3. **recall@10 is the mean, over mined tasks, of the fraction of gold files
|
|
41
|
+
that land in the top 10.** The gold paths are the destination side of the
|
|
42
|
+
patch. A task whose patch is not in the vault is unmeasured, not a miss.
|
|
43
|
+
`simt bench report` prints the figure. It does not fail the cost report
|
|
44
|
+
when the figure cannot be computed.
|
|
45
|
+
|
|
46
|
+
## Consequences
|
|
47
|
+
|
|
48
|
+
`simt map` reads the index and rebuilds it when the cache is empty. The
|
|
49
|
+
comparison against the mined patches is a property of this checkout's index
|
|
50
|
+
and of the vault on this machine. A checkout with no index, or no vault,
|
|
51
|
+
reports the figure as unmeasured. On the 20 mined tasks available here, mean
|
|
52
|
+
recall@10 is 55.6% of gold files. The bar is 60%, and this ranking does not
|
|
53
|
+
meet it.
|
|
54
|
+
|
|
55
|
+
## Rejected
|
|
56
|
+
|
|
57
|
+
- **One-directional edges.** Rank would flow only toward the imported file, and
|
|
58
|
+
the test that imports the file the task named would never surface.
|
|
59
|
+
- **A zero when the gold patch is absent.** That number would punish a machine
|
|
60
|
+
for a file the benchmark hides, and it would read as a measurement.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ADR-092 — A test is an import, a conventional neighbour, or a coverage record
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R24 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/tests.mjs`, `src/commands/tests.mjs`, `test/mapping.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
The index records imports. A test file that imports a source file is evidence
|
|
10
|
+
that the test exercises it, and so is a coverage report that names both. A
|
|
11
|
+
shared commit is weaker: most commits touch a test and several sources that
|
|
12
|
+
the test does not exercise.
|
|
13
|
+
|
|
14
|
+
The line extractor reads one line at a time, so an import whose specifier sits
|
|
15
|
+
on the next line is absent from the stored edges. The mapping has to see that
|
|
16
|
+
specifier or it misses the tests this repository actually writes.
|
|
17
|
+
|
|
18
|
+
## Rulings
|
|
19
|
+
|
|
20
|
+
1. **The default answer uses only the precise edges.** Kind `test`, weight 1.
|
|
21
|
+
A test file imports a tracked source file, including a dynamic `import()`.
|
|
22
|
+
Or the conventional source beside the test exists: `foo_test.go` beside
|
|
23
|
+
`foo.go`, `test_foo.py` beside `foo.py`, `FooTest` beside `Foo` in the same
|
|
24
|
+
directory, `foo.test.mjs` beside `foo.mjs`, and `src/test/java` mirrored to
|
|
25
|
+
`src/main/java`. Or a coverage record names both sides. lcov pairs a source
|
|
26
|
+
only when the test name is non-empty. JaCoCo pairs a source file with the
|
|
27
|
+
one class whose stem is that file plus `Test` or `Tests`. A Go profile
|
|
28
|
+
counts a hit, and the test is `stem_test.go` in that directory, or the
|
|
29
|
+
single `*_test.go` there. A file that is not one of those is not an edge.
|
|
30
|
+
|
|
31
|
+
2. **Co-change is a different kind, and it is off unless asked.** Kind
|
|
32
|
+
`cochange`. A pair counts when it appears in at least two commits. A commit
|
|
33
|
+
with more than five tests or more than fifteen sources is skipped. Each
|
|
34
|
+
test keeps its eight strongest pairs. History is the last 200 non-merge
|
|
35
|
+
commits. `simt tests --cochange` appends those files after the precise
|
|
36
|
+
ones. A git failure leaves the history empty; the import map still answers.
|
|
37
|
+
|
|
38
|
+
3. **The edges are computed when asked, and not stored.** The index write
|
|
39
|
+
replaces every edge of a changed path. Storing the test map in that write
|
|
40
|
+
would drop the import edges, and reading the commit log inside a reindex
|
|
41
|
+
would miss the one-file time bar. Coverage is read from a fixed list of
|
|
42
|
+
paths. A path that is absent is not a failure.
|
|
43
|
+
|
|
44
|
+
4. **Precision is the share of predicted tests that the label also names.**
|
|
45
|
+
An empty prediction does not pass. On the 30 hand-labelled files in this
|
|
46
|
+
checkout the figure is 1.0. The bar is 0.8.
|
|
47
|
+
|
|
48
|
+
## Consequences
|
|
49
|
+
|
|
50
|
+
`simt tests` with no path prints how many test edges and how many co-change
|
|
51
|
+
edges the tree has. `simt tests <path>` lists the tests for that file. A path
|
|
52
|
+
the index does not contain is a usage error. A directory that is not a git
|
|
53
|
+
repository is unsupported.
|
|
54
|
+
|
|
55
|
+
## Rejected
|
|
56
|
+
|
|
57
|
+
- **A stem that is unique somewhere else in the tree.** The test named for a
|
|
58
|
+
directory is not the test of the one file that happens to share that stem.
|
|
59
|
+
- **Writing the test edges into the index.** The next reindex of a touched
|
|
60
|
+
path would have to know about every edge kind or it would delete them.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ADR-093 — why names what governs a file
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R25 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/govern.mjs`, `src/commands/why.mjs`, `test/why.test.mjs`,
|
|
6
|
+
`test/why-fixture.test.mjs`, `test/why-snapshot.test.mjs`
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
A decision says which files it governs in an Affects line. The line is prose.
|
|
11
|
+
It wraps. It names a command, a path from the repository root, a path from a
|
|
12
|
+
source directory, or a phrase that is not a location at all. A reader who
|
|
13
|
+
greps the word Affects still has to decide which of those a backtick was.
|
|
14
|
+
|
|
15
|
+
Rules, skills, gates and lessons name paths in their own way. The question
|
|
16
|
+
`why` answers is the same for each: does this one govern this file?
|
|
17
|
+
|
|
18
|
+
## Rulings
|
|
19
|
+
|
|
20
|
+
1. **The Affects line contributes the tokens in backticks, and nothing else.**
|
|
21
|
+
Continuation lines are part of the paragraph. A blank line, a heading, or
|
|
22
|
+
the next field ends it. The token is resolved in order: a command prefixed
|
|
23
|
+
with the tool name, when that command exists; a path or a glob at the
|
|
24
|
+
repository root; the same path under each directory named src. The tool
|
|
25
|
+
name alone is not a command. A token that is none of these is reported as
|
|
26
|
+
`UNRESOLVED-AFFECTS`, with the record it came from. It is not dropped and
|
|
27
|
+
it is not guessed.
|
|
28
|
+
|
|
29
|
+
2. **The other governors are the path lists they already publish.** A rule
|
|
30
|
+
governs a file its scope matches. An applies entry governs a file only when
|
|
31
|
+
the entry is itself a path. A skill governs a file its paths match. A
|
|
32
|
+
staged gate governs a file of an extension it formats, and a manifest
|
|
33
|
+
command governs a file it names. A lesson governs a file its trigger names.
|
|
34
|
+
A missing lessons directory, a missing skill registry, or a missing
|
|
35
|
+
manifest is an empty list.
|
|
36
|
+
|
|
37
|
+
3. **The answer is computed when asked, and not stored.** The file list is
|
|
38
|
+
the paths git tracks. Writing the index to answer the question would change
|
|
39
|
+
the tree the question is about.
|
|
40
|
+
|
|
41
|
+
## Consequences
|
|
42
|
+
|
|
43
|
+
`simt why <path>` prints one line for each decision, rule, skill, gate and
|
|
44
|
+
lesson that governs that path. `simt why` with no path prints the unresolved
|
|
45
|
+
Affects tokens. A path git does not track is a usage error. A directory that
|
|
46
|
+
is not a git repository is unsupported.
|
|
47
|
+
|
|
48
|
+
On the tree at 4c27514, sixteen Affects lines were the whole set, and the
|
|
49
|
+
only record naming the dispatcher was ADR-017. Records written after that
|
|
50
|
+
commit name it as well, and `why` lists those too.
|
|
51
|
+
|
|
52
|
+
## Rejected
|
|
53
|
+
|
|
54
|
+
- **Attaching every always-on rule to every file.** The rule governs the
|
|
55
|
+
repository. Listing it on each file would hide the ones that actually name
|
|
56
|
+
the file.
|
|
57
|
+
- **Treating an unresolved token as a path that might exist later.** The
|
|
58
|
+
report is the point. A guess would make the report empty.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# ADR-094 — a change runs the tests that import it
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R16 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/gate.mjs`, `src/commands/gate.mjs`, `test/gate.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A gate that always runs the whole suite teaches people to skip it. The tests
|
|
10
|
+
that can see a change are the ones that import it, including through another
|
|
11
|
+
file. A name that looks like a test, and a commit that also touched a test,
|
|
12
|
+
are different facts. They are already answered by `simt tests`.
|
|
13
|
+
|
|
14
|
+
The pre-commit hook, the stop hook and an agent call this command. They do
|
|
15
|
+
not need the dispatcher rewritten to do it.
|
|
16
|
+
|
|
17
|
+
## Rulings
|
|
18
|
+
|
|
19
|
+
1. **Selection is the import closure.** A test file is selected when an import
|
|
20
|
+
reaches a changed file, at any depth. A changed file that is itself a test
|
|
21
|
+
is selected. A cycle is walked once. A conventional neighbour and a shared
|
|
22
|
+
commit are not imports, so they are not a selection. A file the test-path
|
|
23
|
+
rule already treats as a test counts, including a helper that lives beside
|
|
24
|
+
the tests and imports the change.
|
|
25
|
+
2. **The command is `simt gate`.** Paths name the change. `--changed` reads
|
|
26
|
+
the diff, staged, unstaged and untracked. No path and no `--changed` is a
|
|
27
|
+
usage error. A clean diff selects nothing and runs nothing. A repository
|
|
28
|
+
that is not git is unsupported.
|
|
29
|
+
3. **The run is the package's own runner, and only the selected files.** Tests
|
|
30
|
+
are grouped by the package that contains them. The runner is the one that
|
|
31
|
+
package already declares. A package with no runner is unsupported. The
|
|
32
|
+
child's test-runner environment is cleared, so a parent test process cannot
|
|
33
|
+
take the child's result and report a pass.
|
|
34
|
+
4. **The hand list for the plan's named file is 51 files, and the selection
|
|
35
|
+
finished in under 5 seconds.** Eight other test files do not import it, and
|
|
36
|
+
they are not selected. The set is wide because the shared fixture imports
|
|
37
|
+
the loader that imports that file. Measured at about 40 ms.
|
|
38
|
+
|
|
39
|
+
## Consequences
|
|
40
|
+
|
|
41
|
+
`simt tests` still answers "which tests exercise this file", including a
|
|
42
|
+
conventional name and, when asked, a shared commit. `simt gate` answers "run
|
|
43
|
+
the tests this change can reach". Compaction of the runner's text is a later
|
|
44
|
+
slice. The dispatcher is unchanged.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ADR-095 — gate output keeps the failures
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R18 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/compact.mjs`, `src/commands/gate.mjs`, `test/compact.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A gate that prints every passing test spends the reader's attention on the
|
|
10
|
+
lines that did not fail. A summary that drops a failure is worse: the run
|
|
11
|
+
looks cleaner than it was. The formats this tool's own runners emit are
|
|
12
|
+
known. An arbitrary shell log is not one of them, and guessing at it is how
|
|
13
|
+
a line gets lost.
|
|
14
|
+
|
|
15
|
+
## Rulings
|
|
16
|
+
|
|
17
|
+
1. **Compaction applies to a report this tool produced, and only to a format
|
|
18
|
+
it can read.** Those formats are TAP, JUnit XML, vitest or jest JSON, and
|
|
19
|
+
`go test -json`. Pytest's junit file is JUnit XML. A stream that is none
|
|
20
|
+
of these is printed unchanged.
|
|
21
|
+
2. **Failures come first, and each one is kept.** The name, the assertion
|
|
22
|
+
and the location stay in the printed block. Passing tests become a count.
|
|
23
|
+
A skip becomes a count when there is one.
|
|
24
|
+
3. **The raw log path is printed.** The log is gitignored. The command does
|
|
25
|
+
not wrap an arbitrary shell command. The gate is the caller.
|
|
26
|
+
|
|
27
|
+
## Consequences
|
|
28
|
+
|
|
29
|
+
`simt gate` is the command that compacts. Another command that wants the same
|
|
30
|
+
treatment calls the same function. A format that is not in the list stays
|
|
31
|
+
raw until it is added with a golden fixture.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ADR-096 — impact names who a change reaches
|
|
2
|
+
|
|
3
|
+
Status: **accepted** · 2026-10-18 · filed as R23 (plan 0003)
|
|
4
|
+
|
|
5
|
+
**Affects:** `src/index/impact.mjs`, `src/commands/impact.mjs`, `test/impact.test.mjs`
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
A change has a neighbourhood: the files that import it, the tests of that
|
|
10
|
+
neighbourhood, the decisions and rules that govern the file, and the owner a
|
|
11
|
+
CODEOWNERS line names. In a workspace the neighbourhood crosses a repository
|
|
12
|
+
when another one references a contract this change touches. That is the check
|
|
13
|
+
behind "the producer ships before the consumer".
|
|
14
|
+
|
|
15
|
+
## Rulings
|
|
16
|
+
|
|
17
|
+
1. **The argument is a tracked path, or the name of one export.** A private
|
|
18
|
+
symbol is not an export. Several files exporting the same name is a usage
|
|
19
|
+
error that names those files. A name that is neither a path nor an export
|
|
20
|
+
is a usage error. A tree that is not git is unsupported.
|
|
21
|
+
2. **Importers are the non-test files that import the target, at any depth.**
|
|
22
|
+
The tests are the test files that import that closure. Both lists stop at
|
|
23
|
+
64. Past that, the report says it was truncated.
|
|
24
|
+
3. **Governance is what `why` would name for the file, limited to decisions
|
|
25
|
+
and rules.** Owners are the last matching pattern in the first CODEOWNERS
|
|
26
|
+
file that exists. A pattern with no slash matches the file name in any
|
|
27
|
+
directory.
|
|
28
|
+
4. **A cross-repo consumer is another repository whose text contains a
|
|
29
|
+
contract path this change touches.** A contract is an OpenAPI document or
|
|
30
|
+
a JSON Schema file. The path has to include a directory. No workspace
|
|
31
|
+
means no consumers, and that is not a failure. The scan stops at 64
|
|
32
|
+
references and 2000 files in a repository.
|
|
33
|
+
|
|
34
|
+
## Consequences
|
|
35
|
+
|
|
36
|
+
`simt impact` answers who is reached. `simt gate` still runs the tests.
|
|
37
|
+
`simt why` still answers governance on its own, including skills, gates and
|
|
38
|
+
lessons, which this report does not repeat.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "simt-dev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Shared agent rules, skills and git hooks for a repository: one command adopts a repo, and every gate it declares is probed before it is written.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Said Elimam",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"bin",
|
|
21
21
|
"src",
|
|
22
22
|
"rules",
|
|
23
|
+
"templates",
|
|
23
24
|
"docs/decisions",
|
|
24
25
|
"CHANGELOG.md"
|
|
25
26
|
],
|
package/rules/core.md
CHANGED
|
@@ -60,7 +60,7 @@ commands belongs in this repository, not here.
|
|
|
60
60
|
|
|
61
61
|
- No type-system escapes, no unawaited async work, no debug printing in application code.
|
|
62
62
|
- 400 lines is a review trigger; 1000 lines is a hard ceiling. Generated files are exempt
|
|
63
|
-
when the hook names the generator. (ADR-005)
|
|
63
|
+
when the hook names the generator. (ADR-005) <!-- simt-check: max-file-lines -->
|
|
64
64
|
- Never weaken CSP, CORS, auth, audit, cache or validation controls to silence a test, warning
|
|
65
65
|
or alert.
|
|
66
66
|
- Review verdicts are exactly three: `BLOCK`, `APPROVE WITH RESIDUAL RISK` (must list what was
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What a policy decides, given facts and one session's overrides.
|
|
2
|
+
//
|
|
3
|
+
// Pure, so the whole decision is testable without a harness: hand it the facts a
|
|
4
|
+
// renderer would extract and an environment, and it answers. The filesystem is
|
|
5
|
+
// consulted in exactly one place — `generatedBytes`, which asks whether a file being
|
|
6
|
+
// written to holds bytes this tool generates — because that is the one fact a payload
|
|
7
|
+
// cannot carry.
|
|
8
|
+
//
|
|
9
|
+
// Two properties are deliberate and both are about an author standing still:
|
|
10
|
+
//
|
|
11
|
+
// Every matching policy is reported, not the first. `git push --force` is a push and a
|
|
12
|
+
// rewrite; a build that stopped at the first match would let `SIMT_ALLOW=push` lift the
|
|
13
|
+
// approval rule and carry a force-push through, because the rewrite rule was never
|
|
14
|
+
// consulted. Lifting a policy lifts that policy.
|
|
15
|
+
//
|
|
16
|
+
// No fact is not a violation. A payload whose shape we did not read — a harness that
|
|
17
|
+
// renamed a tool, an agent writing through some tool we have never seen — yields no
|
|
18
|
+
// command and no path, and the decision is to say nothing. Turning "we do not
|
|
19
|
+
// understand this" into "blocked" makes the hook a false-positive machine, and a hook
|
|
20
|
+
// that blocks real work is uninstalled the same afternoon.
|
|
21
|
+
|
|
22
|
+
import { readFile } from 'node:fs/promises';
|
|
23
|
+
import { join, relative, resolve } from 'node:path';
|
|
24
|
+
|
|
25
|
+
import { parse } from '../projection/markers.mjs';
|
|
26
|
+
import { ALLOW_ENV, EVENTS, POLICY_IDS, policiesFor } from './policy.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The policies this session has lifted, and the names in `SIMT_ALLOW` that are not
|
|
30
|
+
* policies at all.
|
|
31
|
+
*
|
|
32
|
+
* An unknown name is reported rather than ignored: `SIMT_ALLOW=push,rewrit` typed once
|
|
33
|
+
* and relied on would otherwise look like a lift that did nothing, and the author would
|
|
34
|
+
* conclude the hook was broken rather than the spelling.
|
|
35
|
+
*/
|
|
36
|
+
export function overrides(env = {}) {
|
|
37
|
+
const raw = env[ALLOW_ENV];
|
|
38
|
+
const named = typeof raw === 'string' ? raw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
39
|
+
return {
|
|
40
|
+
ids: new Set(named.filter((n) => POLICY_IDS.includes(n))),
|
|
41
|
+
unknown: named.filter((n) => !POLICY_IDS.includes(n)),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether one row applies to the facts. A row with no matcher reads the facts itself. */
|
|
46
|
+
function applies(row, facts) {
|
|
47
|
+
if (row.inspects === 'command') {
|
|
48
|
+
return typeof facts.command === 'string' && row.match !== null && row.match.test(facts.command);
|
|
49
|
+
}
|
|
50
|
+
if (row.inspects === 'write') {
|
|
51
|
+
return (facts.writes ?? []).some((w) => w.generated !== null && w.generated !== undefined);
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The decision for one event.
|
|
58
|
+
*
|
|
59
|
+
* `blocked` and `lifted` are ids, not rows, because the caller prints them and an id is
|
|
60
|
+
* what a reader types into `SIMT_ALLOW`. The messages carry their own override hint, so
|
|
61
|
+
* a denial is actionable without the reader knowing the variable exists.
|
|
62
|
+
*/
|
|
63
|
+
export function decide({ event = EVENTS[0], facts = {}, env = {} } = {}) {
|
|
64
|
+
const { ids: lifted, unknown } = overrides(env);
|
|
65
|
+
const matched = policiesFor(event).filter((p) => applies(p, facts));
|
|
66
|
+
const blocked = matched.filter((p) => !lifted.has(p.id));
|
|
67
|
+
return {
|
|
68
|
+
event,
|
|
69
|
+
decision: blocked.length > 0 ? 'deny' : 'allow',
|
|
70
|
+
blocked: blocked.map((p) => p.id),
|
|
71
|
+
lifted: matched.filter((p) => lifted.has(p.id)).map((p) => p.id),
|
|
72
|
+
unknown,
|
|
73
|
+
messages: blocked.map((p) => `${p.message} (override: ${ALLOW_ENV}=${p.id})`),
|
|
74
|
+
adrs: blocked.map((p) => p.adr),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Whether this file's bytes are generated: a region this tool writes, or a submodule. */
|
|
79
|
+
function holdsRegion(text) {
|
|
80
|
+
// Both marker styles, because the region is written in whichever one the file's own
|
|
81
|
+
// syntax allows — `<!-- simt:begin -->` in markdown, `# simt:begin` in an
|
|
82
|
+
// .editorconfig. A malformed region is still a region: a begin marker with no end is
|
|
83
|
+
// the file somebody has already been editing by hand.
|
|
84
|
+
return parse(text).status !== 'absent' || parse(text, 'hash').status !== 'absent';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The submodule paths `.gitmodules` declares, as repository-relative directories. */
|
|
88
|
+
async function submodules(root) {
|
|
89
|
+
const text = await readFile(join(root, '.gitmodules'), 'utf8').catch(() => null);
|
|
90
|
+
if (text === null) return [];
|
|
91
|
+
return text
|
|
92
|
+
.split('\n')
|
|
93
|
+
.map((line) => /^\s*path\s*=\s*(.+?)\s*$/.exec(line))
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.map((m) => m[1].replace(/\/+$/, ''));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why this path is not the author's to write, or null when it is.
|
|
100
|
+
*
|
|
101
|
+
* The reason is returned rather than a boolean because the two answers are different
|
|
102
|
+
* facts and the author of a fix needs to know which one applies: a region is repaired by
|
|
103
|
+
* editing its source, a submodule by committing in the other repository.
|
|
104
|
+
*/
|
|
105
|
+
export async function generatedBytes(root, file) {
|
|
106
|
+
const abs = resolve(root, file);
|
|
107
|
+
const text = await readFile(abs, 'utf8').catch(() => null);
|
|
108
|
+
if (text !== null && holdsRegion(text)) return 'region';
|
|
109
|
+
const rel = relative(resolve(root), abs);
|
|
110
|
+
if (!rel.startsWith('..')) {
|
|
111
|
+
const inside = await submodules(root);
|
|
112
|
+
if (inside.some((dir) => rel === dir || rel.startsWith(`${dir}/`))) return 'submodule';
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Turn a renderer's extraction into the facts the table asks about. */
|
|
118
|
+
export async function factsFor(root, { command = null, writes = [] } = {}) {
|
|
119
|
+
const resolved = await Promise.all(
|
|
120
|
+
writes.filter(Boolean).map(async (path) => ({ path, generated: await generatedBytes(root, path) })),
|
|
121
|
+
);
|
|
122
|
+
return { command, writes: resolved };
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The policies an agent-time hook enforces, in one table.
|
|
2
|
+
//
|
|
3
|
+
// One table and four harnesses. The renderers in ./render.mjs map a harness's own
|
|
4
|
+
// payload onto the facts named here and a decision back onto that harness's own JSON,
|
|
5
|
+
// so a policy is written once and cannot be enforced in one harness and forgotten in
|
|
6
|
+
// another — which is the failure the four vendor documents make easy: they agree on
|
|
7
|
+
// nothing, not the config path, not the shape, not the name of the tool that runs a
|
|
8
|
+
// shell command (Bash, Shell, run_command), not the key holding the command, not the
|
|
9
|
+
// JSON the hook answers with. Every one of those differences lives in the renderer
|
|
10
|
+
// beside the vendor page it was read from; the table only knows facts.
|
|
11
|
+
//
|
|
12
|
+
// The facts are deliberately few: the shell command an agent is about to run, and the
|
|
13
|
+
// path it is about to write. A payload the renderer cannot recognise yields no facts,
|
|
14
|
+
// and no fact is not a violation — guessing from a shape we did not read would block
|
|
15
|
+
// real work in a harness whose payload changed under us.
|
|
16
|
+
//
|
|
17
|
+
// The messages are the bytes an author reads while being stopped, so each one names
|
|
18
|
+
// the rule, the record that settled it, and the override that lifts it. An override
|
|
19
|
+
// nobody is told about is a policy the author learns to route around instead of one
|
|
20
|
+
// they can ask about.
|
|
21
|
+
|
|
22
|
+
import { CLI } from '../name.mjs';
|
|
23
|
+
|
|
24
|
+
/** The events an agent-time hook can answer, normalised across harnesses. */
|
|
25
|
+
export const EVENTS = ['pre-tool-use'];
|
|
26
|
+
|
|
27
|
+
/** The environment variable that lifts named policies for one session. */
|
|
28
|
+
export const ALLOW_ENV = 'SIMT_ALLOW';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One row per policy.
|
|
32
|
+
*
|
|
33
|
+
* `inspects` names the fact the matcher is handed, and `match` the condition. The
|
|
34
|
+
* `generated` row has no matcher because what it asks — does this file hold bytes this
|
|
35
|
+
* tool generates — is answered by reading the file, not by the text of a payload.
|
|
36
|
+
*
|
|
37
|
+
* Order matters where two policies match one command: `git push --force` is both a
|
|
38
|
+
* push and a rewrite, so both are reported and both have to be lifted before it runs.
|
|
39
|
+
*/
|
|
40
|
+
const POLICIES = [
|
|
41
|
+
{
|
|
42
|
+
id: 'push',
|
|
43
|
+
adr: 'ADR-001',
|
|
44
|
+
event: 'pre-tool-use',
|
|
45
|
+
inspects: 'command',
|
|
46
|
+
match: /(?:^|[\s;&|(])git\s+push(?:\s|$)/,
|
|
47
|
+
message: `Push needs explicit approval (ADR-001); ask the user.`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'rewrite',
|
|
51
|
+
adr: 'ADR-017',
|
|
52
|
+
event: 'pre-tool-use',
|
|
53
|
+
inspects: 'command',
|
|
54
|
+
match:
|
|
55
|
+
/--no-verify|git\s+commit\b[^;&|]*\s--amend|git\s+reset\s+--hard|git\s+rebase\b|git\s+push\b[^;&|]*(?:--force\b|\s-f\b)/,
|
|
56
|
+
message:
|
|
57
|
+
`Never amend, rebase, reset --hard, force-push or --no-verify unless asked; ` +
|
|
58
|
+
`each is the escape that turns the declared gates into a suggestion (ADR-017).`,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'generated',
|
|
62
|
+
adr: 'ADR-036',
|
|
63
|
+
event: 'pre-tool-use',
|
|
64
|
+
inspects: 'write',
|
|
65
|
+
match: null,
|
|
66
|
+
message:
|
|
67
|
+
`That file carries bytes this tool generates, so an edit there is overwritten on the next ` +
|
|
68
|
+
`write; edit the source the region comes from, then run \`${CLI} sync --yes\` (ADR-036).`,
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/** The policy ids, in table order: the vocabulary an override is checked against. */
|
|
73
|
+
export const POLICY_IDS = POLICIES.map((p) => p.id);
|
|
74
|
+
|
|
75
|
+
/** The rows for one event, so a renderer and the table cannot disagree about events. */
|
|
76
|
+
export function policiesFor(event) {
|
|
77
|
+
return POLICIES.filter((p) => p.event === event);
|
|
78
|
+
}
|