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,360 @@
|
|
|
1
|
+
// The CI gate, as a file a repo owns.
|
|
2
|
+
//
|
|
3
|
+
// `simt-dev check` is the whole gate: projection drift, an unreadable rule, an
|
|
4
|
+
// unrunnable gate, a stale manifest. The pre-commit hook runs it locally, which
|
|
5
|
+
// stops one person committing a broken region and stops nobody else - a hook is a
|
|
6
|
+
// developer's machine, and every review is somebody else's. So the same command
|
|
7
|
+
// ships as a workflow that runs the gate where a pull request is actually merged.
|
|
8
|
+
//
|
|
9
|
+
// Two decisions that are easy to get wrong and are load-bearing:
|
|
10
|
+
//
|
|
11
|
+
// - The BYTES are copied, not referenced. A GitHub `uses:` line would make this
|
|
12
|
+
// one file owned by a repository the adopting team cannot read, cannot edit and
|
|
13
|
+
// cannot diff, changing under them whenever that repo moves. A copied file is
|
|
14
|
+
// reviewable in the PR that adopts it, which is the only review that happens.
|
|
15
|
+
//
|
|
16
|
+
// - It is written ONCE and never rewritten. From that point it is the repo's
|
|
17
|
+
// file: their extra jobs, their triggers, their matrix. Overwriting it would
|
|
18
|
+
// delete work this tool has no way to know about, which is the same defect as
|
|
19
|
+
// replacing a hand-written `.mdc` body. A template improvement therefore does
|
|
20
|
+
// not reach an adopted repo on its own - stated in ADR-023 rather than
|
|
21
|
+
// discovered later.
|
|
22
|
+
//
|
|
23
|
+
// - simt-dev's OWN repository is the exception, and the exception has to be the
|
|
24
|
+
// file's own words. Everywhere else the file is a repo's; here it is a copy of
|
|
25
|
+
// the thing it is copied from, and a copy that is allowed to rot stops being a
|
|
26
|
+
// template. A reader who opens this file in this repository and sees a diff has
|
|
27
|
+
// to be able to tell it apart from a hand edit without reading a decision record,
|
|
28
|
+
// which is why the header says so in the file rather than in this comment.
|
|
29
|
+
//
|
|
30
|
+
// The own copy also RUNS something different, and that is not a variation for taste.
|
|
31
|
+
// `simt-dev check` is red in this repository by design: AGENTS.md here is written by
|
|
32
|
+
// hand and deliberately never self-adopted (see its own "Why this repository is not
|
|
33
|
+
// self-adopted"), so the check reports it as unmanaged forever. A gate that is red on
|
|
34
|
+
// the day it lands is the one thing worse than no gate - it teaches the reader to
|
|
35
|
+
// ignore it - so the own copy runs what this repository actually declares, which is
|
|
36
|
+
// what `.githooks/pre-push` already runs.
|
|
37
|
+
//
|
|
38
|
+
// The directory decides whether it applies at all: a repo with no
|
|
39
|
+
// `.github/workflows` is not a GitHub Actions repo, and silently creating that
|
|
40
|
+
// directory starts running CI on someone's pull requests they did not ask for. The
|
|
41
|
+
// own repository is exempt from that too, for the same reason it is exempt from
|
|
42
|
+
// write-once: it is the only repo whose Actions directory this tool is entitled to
|
|
43
|
+
// decide the contents of.
|
|
44
|
+
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
47
|
+
import { join, resolve } from 'node:path';
|
|
48
|
+
|
|
49
|
+
import { PACKAGE_ROOT } from '../workspace.mjs';
|
|
50
|
+
import { PACKAGE_NAME, WORKFLOW_FILE_NAME } from '../name.mjs';
|
|
51
|
+
import { renderLocator } from '../locate.mjs';
|
|
52
|
+
import { CLI } from '../name.mjs';
|
|
53
|
+
|
|
54
|
+
/** Directory whose presence means this repo runs GitHub Actions. */
|
|
55
|
+
export const WORKFLOWS_DIR = '.github/workflows';
|
|
56
|
+
|
|
57
|
+
/** The one file this module writes. */
|
|
58
|
+
export const WORKFLOW_FILE = `${WORKFLOWS_DIR}/${WORKFLOW_FILE_NAME}`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether this repo is the one this code is running from.
|
|
62
|
+
*
|
|
63
|
+
* `resolve` and not `realpath`, deliberately: a repo reached through a symlinked
|
|
64
|
+
* path fails to match, and that is the safe direction. It would be written once and
|
|
65
|
+
* never refreshed - the ruling every other repo lives under - where a realpath that
|
|
66
|
+
* matched a symlink would let an alias of this repo rewrite a file nobody thought of
|
|
67
|
+
* as managed.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} root repo root
|
|
70
|
+
* @returns {boolean}
|
|
71
|
+
*/
|
|
72
|
+
export function isOwnRepo(root) {
|
|
73
|
+
return resolve(root) === PACKAGE_ROOT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The header. The only part of the file that says who owns it, and the place the
|
|
78
|
+
* exception has to live: a reader of this file who has only this repository has to be
|
|
79
|
+
* able to tell, from these bytes, that simt-dev rewrites it here and nowhere else.
|
|
80
|
+
*
|
|
81
|
+
* @param {boolean} own whether this is simt-dev's own repository
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function header(own) {
|
|
85
|
+
if (own) {
|
|
86
|
+
return `# Written by simt-dev, and refreshed by it. This repository is simt-dev's
|
|
87
|
+
# own, so this file is a copy of the template rather than a repository's own: a diff
|
|
88
|
+
# here means the GATE changed, not that somebody hand-edited their CI. Anywhere else
|
|
89
|
+
# this file is written once by \`${CLI} init\` and never touched again.
|
|
90
|
+
#
|
|
91
|
+
# To change what the gate does, change \`src/ci/workflow.mjs\` in the same commit and
|
|
92
|
+
# run \`${CLI} ci --yes\`. Editing this file by hand works until the next template
|
|
93
|
+
# change overwrites it.
|
|
94
|
+
#
|
|
95
|
+
# The steps below run what this repository declares - its tests and its registry lint,
|
|
96
|
+
# the same two \`.githooks/pre-push\` runs - and NOT \`${CLI} check\`. That check is
|
|
97
|
+
# red here by design: this AGENTS.md is written by hand and deliberately never
|
|
98
|
+
# self-adopted, so the check reports it as unmanaged on every run. A gate that is red
|
|
99
|
+
# the day it lands is worse than no gate.
|
|
100
|
+
#
|
|
101
|
+
# Every step runs from this checkout: this package has zero dependencies, so there is
|
|
102
|
+
# nothing to install and no binary to resolve. Nothing here can pass by skipping - a
|
|
103
|
+
# step that cannot run fails the job.
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
return `# Written by simt-dev when this repo was adopted. Yours from here: ${CLI}
|
|
107
|
+
# creates this file once and never rewrites it, so your own jobs and triggers stay
|
|
108
|
+
# yours.
|
|
109
|
+
#
|
|
110
|
+
# It runs one command, \`${CLI} check\` - the same gate the pre-commit hook runs,
|
|
111
|
+
# in the place a pull request is actually merged. A hook guards one developer's
|
|
112
|
+
# machine; this guards the merge.
|
|
113
|
+
#
|
|
114
|
+
# If ${CLI} cannot be found the job FAILS instead of passing. A green tick from a
|
|
115
|
+
# check that never ran is the one outcome worse than a red one.
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Actions the templates run, pinned to the commit that runs them.
|
|
121
|
+
*
|
|
122
|
+
* A tag is a name that moves. `actions/checkout@v4` resolved to whatever that project
|
|
123
|
+
* published under the name today, so the bytes executing a gate are not the bytes the
|
|
124
|
+
* adopting repository reviewed, and a change to them arrives as a force-push or a
|
|
125
|
+
* compromised token rather than as a diff. The pin is the commit; the comment beside it
|
|
126
|
+
* is the release a reader recognises, and Dependabot's `github-actions` ecosystem
|
|
127
|
+
* rewrites the pair together - which is why the pin needs no maintenance here.
|
|
128
|
+
*
|
|
129
|
+
* The major version is deliberately the one the templates already used rather than the
|
|
130
|
+
* newest available. `checkout` v7 and `setup-node` v7 both carry breaking changes, and
|
|
131
|
+
* a gate whose behaviour changes because somebody re-ran an updater is precisely what
|
|
132
|
+
* pinning exists to prevent. Moving a major is one reviewed line in this table.
|
|
133
|
+
*/
|
|
134
|
+
export const ACTIONS = {
|
|
135
|
+
checkout: { repo: 'actions/checkout', commit: '11d5960a326750d5838078e36cf38b85af677262', release: 'v4.4.0' },
|
|
136
|
+
setupNode: { repo: 'actions/setup-node', commit: '49933ea5288caeca8642d1e84afbd3f7d6820020', release: 'v4.4.0' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* One `- uses:` line, pinned.
|
|
141
|
+
*
|
|
142
|
+
* @param {keyof typeof ACTIONS} action
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function uses(action) {
|
|
146
|
+
const { repo, commit, release } = ACTIONS[action];
|
|
147
|
+
return ` - uses: ${repo}@${commit} # ${release}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The YAML every copy shares, so the two cannot drift on triggers, permissions or
|
|
151
|
+
* concurrency. Only `name`, the runner and `steps` vary, and only because they have
|
|
152
|
+
* to. The own gate also varies the platforms and the Node versions, because this
|
|
153
|
+
* package is the one whose floor and whose ADR-012 claim have to hold on more than
|
|
154
|
+
* the minimum. */
|
|
155
|
+
function skeleton(name, steps, { matrix = null } = {}) {
|
|
156
|
+
const axes = matrix
|
|
157
|
+
? Object.entries(matrix)
|
|
158
|
+
.map(([axis, values]) => ` ${axis}: [${values.map((v) => `'${v}'`).join(', ')}]`)
|
|
159
|
+
.join('\n')
|
|
160
|
+
: '';
|
|
161
|
+
const strategy = axes === '' ? '' : ` strategy:\n fail-fast: false\n matrix:\n${axes}\n`;
|
|
162
|
+
// The runner and the matrix have to be read together: naming the platforms in the
|
|
163
|
+
// matrix and leaving `runs-on` on one literal is how a job ends up running six times
|
|
164
|
+
// on the one runner. One of the two is derived from the other rather than written
|
|
165
|
+
// twice, so a matrix that grows an axis cannot be half-honoured.
|
|
166
|
+
// `${{ matrix.os }}` is a template-literal hazard - `${` opens an interpolation - so
|
|
167
|
+
// it is written escaped, the same way the `concurrency` line below spells its own two.
|
|
168
|
+
const runner = matrix?.os ? '\${{ matrix.os }}' : 'ubuntu-latest';
|
|
169
|
+
const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
|
|
170
|
+
return `name: ${name}
|
|
171
|
+
|
|
172
|
+
on:
|
|
173
|
+
pull_request:
|
|
174
|
+
push:
|
|
175
|
+
branches:
|
|
176
|
+
- main
|
|
177
|
+
- master
|
|
178
|
+
|
|
179
|
+
# Read-only is enough to check a repo, and a gate job does not need to write to it.
|
|
180
|
+
permissions:
|
|
181
|
+
contents: read
|
|
182
|
+
|
|
183
|
+
concurrency:
|
|
184
|
+
group: simt-dev-\${{ github.workflow }}-\${{ github.ref }}
|
|
185
|
+
cancel-in-progress: true
|
|
186
|
+
|
|
187
|
+
jobs:
|
|
188
|
+
check:
|
|
189
|
+
name: ${job}
|
|
190
|
+
runs-on: ${runner}
|
|
191
|
+
${strategy} timeout-minutes: 5
|
|
192
|
+
steps:
|
|
193
|
+
${steps}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The minimum Node `engines.node` allows, without the range operator. Both
|
|
197
|
+
* templates pin setup-node to it, read from package.json so the two cannot name
|
|
198
|
+
* a version the package says it does not run on. */
|
|
199
|
+
export function nodeFloor() {
|
|
200
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
201
|
+
const range = pkg.engines?.node;
|
|
202
|
+
const version = typeof range === 'string' ? />=\s*(\d+\.\d+\.\d+)/.exec(range) : null;
|
|
203
|
+
if (!version) throw new Error(`engines.node must be a >=x.y.z floor, got ${JSON.stringify(range)}`);
|
|
204
|
+
return version[1];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The floor in this package's `engines.node`. A gate that cannot start on the
|
|
208
|
+
* version it declares support for is not a gate. The comment ships in the file,
|
|
209
|
+
* because the reader deciding whether to bump that version has only the file.
|
|
210
|
+
* The own gate passes the matrix expression; every other copy passes the floor. */
|
|
211
|
+
function setupNode(version) {
|
|
212
|
+
return `${uses('setupNode')}
|
|
213
|
+
with:
|
|
214
|
+
# The floor in this package's \`engines.node\`. A gate that cannot start on
|
|
215
|
+
# the version it declares support for is not a gate.
|
|
216
|
+
node-version: '${version}'`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Indent a rendered shell block so it sits inside a YAML block scalar. Blank lines
|
|
220
|
+
* stay blank: a line of trailing whitespace inside a scalar is a diff nobody meant. */
|
|
221
|
+
function indent(text, spaces) {
|
|
222
|
+
return text
|
|
223
|
+
.split('\n')
|
|
224
|
+
.map((l) => (l === '' ? '' : `${' '.repeat(spaces)}${l}`))
|
|
225
|
+
.join('\n');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The workflow, as bytes.
|
|
230
|
+
*
|
|
231
|
+
* Two renderings, and the argument is which repository this one is for. `own` is the
|
|
232
|
+
* copy that lives in simt-dev's own repository; it is refreshed rather than left alone,
|
|
233
|
+
* and it runs this repository's own gate rather than the adopting repository's.
|
|
234
|
+
*
|
|
235
|
+
* @param {{own?: boolean}} [options]
|
|
236
|
+
* @returns {string} YAML, LF-terminated
|
|
237
|
+
*/
|
|
238
|
+
export function renderWorkflow({ own = false } = {}) {
|
|
239
|
+
if (own) {
|
|
240
|
+
const floor = nodeFloor();
|
|
241
|
+
return `${header(true)}${skeleton(
|
|
242
|
+
'simt-dev own gate',
|
|
243
|
+
[
|
|
244
|
+
uses('checkout'),
|
|
245
|
+
setupNode('${{ matrix.node }}'),
|
|
246
|
+
// The two checks `.githooks/pre-push` runs, named here rather than discovered,
|
|
247
|
+
// because a reader of this file has to be able to answer "what stops a merge"
|
|
248
|
+
// without opening a shell script in another directory.
|
|
249
|
+
' - name: tests',
|
|
250
|
+
' run: npm test',
|
|
251
|
+
' - name: skill registry lint',
|
|
252
|
+
' run: node bin/simt-dev.mjs graph lint',
|
|
253
|
+
' - name: the gate copy in this repo is current',
|
|
254
|
+
// The drift this exception exists to prevent, checked where it would be caught.
|
|
255
|
+
// Without it a template change lands with the copy still describing the old
|
|
256
|
+
// gate, and the next person to read the diff cannot tell which one is right.
|
|
257
|
+
//
|
|
258
|
+
// `--check`, and not the bare command: `ci` previews by default and exits 0,
|
|
259
|
+
// so this step used to print the drift it exists to catch and pass anyway.
|
|
260
|
+
' run: node bin/simt-dev.mjs ci --check',
|
|
261
|
+
].join('\n'),
|
|
262
|
+
// ADR-012 says CI verifies on macOS and Linux. Until this matrix existed that
|
|
263
|
+
// sentence was false in the one repository that could have made it true, and no
|
|
264
|
+
// check read it - the package declares `os` for npm to enforce, and nothing here
|
|
265
|
+
// ever ran a test on a second platform.
|
|
266
|
+
{ matrix: { os: ['ubuntu-latest', 'macos-latest'], node: [floor, '24', '26'] } },
|
|
267
|
+
)}\n`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const locator = renderLocator('check --root "$ROOT"', {
|
|
271
|
+
remedy: `REMEDY run: npm i -D ${PACKAGE_NAME} (the gate runs from the pinned dependency)`,
|
|
272
|
+
});
|
|
273
|
+
return `${header(false)}${skeleton(
|
|
274
|
+
`${CLI} check`,
|
|
275
|
+
[
|
|
276
|
+
uses('checkout'),
|
|
277
|
+
setupNode(nodeFloor()),
|
|
278
|
+
' - name: install dependencies',
|
|
279
|
+
' # \`npm ci\` needs a lockfile; a repo without one is still gated, not skipped.',
|
|
280
|
+
' run: npm ci || npm install',
|
|
281
|
+
` - name: ${CLI} check`,
|
|
282
|
+
' run: |',
|
|
283
|
+
indent(`set -eu\nROOT="\${GITHUB_WORKSPACE}"\n${locator}`, 10),
|
|
284
|
+
].join('\n'),
|
|
285
|
+
)}\n`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* What this repo's gate file should be, and what should happen to it.
|
|
290
|
+
*
|
|
291
|
+
* The one place that knows about the write-once rule, so `init`, `check` and `ci`
|
|
292
|
+
* cannot disagree about which repo owns its file.
|
|
293
|
+
*
|
|
294
|
+
* @param {string} root repo root
|
|
295
|
+
* @returns {Promise<{file:string, path:string, action:'create'|'refresh'|'unchanged'|'skip',
|
|
296
|
+
* own:boolean, exists:boolean, bytes:number, current:boolean,
|
|
297
|
+
* workflows:boolean, reason:string|null}>}
|
|
298
|
+
*/
|
|
299
|
+
export async function planWorkflow(root) {
|
|
300
|
+
const path = join(root, WORKFLOW_FILE);
|
|
301
|
+
const own = isOwnRepo(root);
|
|
302
|
+
let text = null;
|
|
303
|
+
try {
|
|
304
|
+
text = await readFile(path, 'utf8');
|
|
305
|
+
} catch {
|
|
306
|
+
text = null;
|
|
307
|
+
}
|
|
308
|
+
const exists = text !== null;
|
|
309
|
+
const bytes = exists ? Buffer.byteLength(text) : 0;
|
|
310
|
+
|
|
311
|
+
// An unreadable path is not an absent one. ENOENT is absence; anything else means
|
|
312
|
+
// the file is there and this tool cannot see it, and reporting that as "create"
|
|
313
|
+
// would hand the writer an EACCES at the worst moment.
|
|
314
|
+
let workflows = false;
|
|
315
|
+
try {
|
|
316
|
+
workflows = (await stat(join(root, WORKFLOWS_DIR))).isDirectory();
|
|
317
|
+
} catch {
|
|
318
|
+
workflows = false;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (exists) {
|
|
322
|
+
// Content is only ever compared in simt-dev's own repository. Everywhere else
|
|
323
|
+
// that comparison is the thing ADR-023 ruled out: a repo's file is the repo's
|
|
324
|
+
// file, and the only question is whether one is there.
|
|
325
|
+
const desired = renderWorkflow({ own: true });
|
|
326
|
+
const current = own ? text === desired : true;
|
|
327
|
+
return {
|
|
328
|
+
file: WORKFLOW_FILE,
|
|
329
|
+
path,
|
|
330
|
+
action: current ? 'unchanged' : 'refresh',
|
|
331
|
+
own,
|
|
332
|
+
exists,
|
|
333
|
+
bytes,
|
|
334
|
+
current,
|
|
335
|
+
// Both sides, so a reader told the file differs can be shown how. Rendering a
|
|
336
|
+
// second time for a diff would risk a second answer, and the two call sites
|
|
337
|
+
// must not be able to disagree about what "refresh" means.
|
|
338
|
+
source: { text, desired },
|
|
339
|
+
workflows,
|
|
340
|
+
reason: current ? null : `this repository is simt-dev's own, and the gate template has changed since this copy was written`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
file: WORKFLOW_FILE,
|
|
345
|
+
path,
|
|
346
|
+
// The own repository creates the directory. It is the one repo whose Actions
|
|
347
|
+
// surface this tool is entitled to populate, and the rule that stopped it from
|
|
348
|
+
// inventing CI on somebody's pull requests does not apply to CI on its own.
|
|
349
|
+
action: workflows || own ? 'create' : 'skip',
|
|
350
|
+
own,
|
|
351
|
+
exists,
|
|
352
|
+
bytes,
|
|
353
|
+
current: false,
|
|
354
|
+
workflows,
|
|
355
|
+
reason:
|
|
356
|
+
workflows || own
|
|
357
|
+
? null
|
|
358
|
+
: `no ${WORKFLOWS_DIR} directory, so this repo does not run GitHub Actions yet`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// simt-dev ci — the .github files, in the one repository whose copies are refreshed.
|
|
2
|
+
//
|
|
3
|
+
// Why this exists rather than a flag on `init`: the write-once rule is about a
|
|
4
|
+
// repository's own file, and simt-dev's own repository has no file of its own here —
|
|
5
|
+
// its copies are the templates. Something has to be able to write them without
|
|
6
|
+
// adopting the repository, and `init` cannot: adopting this repo would pin its own
|
|
7
|
+
// SHA into its own AGENTS.md, which is precisely why AGENTS.md says it is not
|
|
8
|
+
// self-adopted. A refresh reachable only through `init` would be an exception that
|
|
9
|
+
// never fires, and an exception that never fires is the "permanently red check
|
|
10
|
+
// nothing can fix" failure in another costume.
|
|
11
|
+
//
|
|
12
|
+
// Everywhere else this command refuses rather than creating either file. There, the
|
|
13
|
+
// file belongs to the repository, `init` writes it once, and a second writer would be
|
|
14
|
+
// a second answer to who owns it (ADR-023, ADR-024, ADR-026).
|
|
15
|
+
//
|
|
16
|
+
// Both files are handled here rather than in two commands because they answer the
|
|
17
|
+
// same question with the same answer. A reader who had to learn which of two
|
|
18
|
+
// commands owns which file would be learning about ownership twice to learn nothing
|
|
19
|
+
// new, and the next .github file would arrive with a third command to remember.
|
|
20
|
+
|
|
21
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
|
|
24
|
+
import { EXIT, emit, emitJson, isJson, note, writeMode } from '../output/index.mjs';
|
|
25
|
+
import { planWorkflow, renderWorkflow, WORKFLOW_FILE } from '../ci/workflow.mjs';
|
|
26
|
+
import { planDependabot, renderDependabot, DEPENDABOT_FILE } from '../ci/dependabot.mjs';
|
|
27
|
+
import { CLI } from '../name.mjs';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The two plans, with the render and the plan-time data each needs kept attached.
|
|
31
|
+
*
|
|
32
|
+
* A table rather than two call sites so that adding a third `.github` file is one
|
|
33
|
+
* row and not a second copy of the write path — the divergence risk this command
|
|
34
|
+
* already had once, when the workflow's own rendering lived inline here.
|
|
35
|
+
*/
|
|
36
|
+
function targets() {
|
|
37
|
+
return [
|
|
38
|
+
{ key: 'workflow', file: WORKFLOW_FILE, plan: planWorkflow, render: () => renderWorkflow({ own: true }) },
|
|
39
|
+
{ key: 'dependabot', file: DEPENDABOT_FILE, plan: planDependabot, render: (plan) => renderDependabot(plan.facts ?? [], { stack: plan.stack ?? [], own: true }) },
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function ciCommand(positional, flags) {
|
|
44
|
+
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
45
|
+
const mode = writeMode(flags);
|
|
46
|
+
const checkOnly = flags.check === true;
|
|
47
|
+
|
|
48
|
+
const plans = [];
|
|
49
|
+
for (const { key, file, plan } of targets()) {
|
|
50
|
+
plans.push({ key, file, ...(await plan(root)) });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Refuse once, on the first non-own plan, and say which file. One refusal that
|
|
54
|
+
// names a file is answerable; two refusals is a list the reader has to reconcile
|
|
55
|
+
// against a rule they cannot see from here.
|
|
56
|
+
const foreign = plans.find((p) => !p.own);
|
|
57
|
+
if (foreign) {
|
|
58
|
+
return failOutside(root, foreign, flags);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const todo = plans.filter((p) => p.action !== 'unchanged');
|
|
62
|
+
|
|
63
|
+
if (todo.length === 0) {
|
|
64
|
+
if (isJson(flags)) {
|
|
65
|
+
emitJson(
|
|
66
|
+
{
|
|
67
|
+
ok: true,
|
|
68
|
+
command: 'ci',
|
|
69
|
+
root,
|
|
70
|
+
action: 'unchanged',
|
|
71
|
+
files: plans.map((p) => ({ file: p.file, bytes: p.bytes })),
|
|
72
|
+
written: false,
|
|
73
|
+
},
|
|
74
|
+
{},
|
|
75
|
+
);
|
|
76
|
+
} else {
|
|
77
|
+
emit(plans.map((p) => `current ${p.file} ${p.bytes} bytes`).join('\n'));
|
|
78
|
+
}
|
|
79
|
+
return EXIT.OK;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Same shape as every other write in this tool: show it, change it only when told.
|
|
83
|
+
// The plan is printed from the same call `check` reads, so a reader who is shown
|
|
84
|
+
// these lines is shown the lines that produced them.
|
|
85
|
+
const lines = [`repo ${root}`];
|
|
86
|
+
for (const p of todo) {
|
|
87
|
+
lines.push(
|
|
88
|
+
`file ${p.file}`,
|
|
89
|
+
`action ${p.action}`,
|
|
90
|
+
`why ${p.reason ?? (p.action === 'create' ? `this repository is simt-dev's own` : '')}`,
|
|
91
|
+
);
|
|
92
|
+
if (p.ecosystems) lines.push(`watching ${p.ecosystems.join(', ') || '(github actions only)'}`);
|
|
93
|
+
// A check that names the drift without showing it makes the reader open two files
|
|
94
|
+
// to answer a question this command already knows the answer to.
|
|
95
|
+
if (checkOnly && p.source) {
|
|
96
|
+
const d = unified(p.file, p.source.text, p.source.desired);
|
|
97
|
+
if (d !== '') lines.push(d);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `ci --check` is the form the own workflow runs, and the whole point of it is the
|
|
102
|
+
// exit code. It printed the plan and returned 0, so the step whose own comment says
|
|
103
|
+
// it exists to catch this drift let through exactly the drift it was written for.
|
|
104
|
+
if (checkOnly) {
|
|
105
|
+
if (isJson(flags)) {
|
|
106
|
+
emitJson(
|
|
107
|
+
{
|
|
108
|
+
ok: false,
|
|
109
|
+
command: 'ci --check',
|
|
110
|
+
root,
|
|
111
|
+
files: todo.map(publicPlan),
|
|
112
|
+
written: false,
|
|
113
|
+
error: `${todo.length} of ${plans.length} .github file(s) in this repo are stale`,
|
|
114
|
+
remedy: `run: ${CLI} ci --yes`,
|
|
115
|
+
},
|
|
116
|
+
{},
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
emit(lines.join('\n'));
|
|
120
|
+
}
|
|
121
|
+
return EXIT.FAIL;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isJson(flags)) {
|
|
125
|
+
emitJson({ ok: true, command: 'ci', root, files: todo.map(publicPlan), written: false, dryRun: !mode.apply }, {});
|
|
126
|
+
} else {
|
|
127
|
+
emit(lines.join('\n'));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!mode.apply) {
|
|
131
|
+
note('');
|
|
132
|
+
note(mode.dryRun ? 'dry-run nothing written' : `dry-run nothing written; apply with: ${CLI} ci --yes`);
|
|
133
|
+
return EXIT.OK;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const { key, plan, render } of targets()) {
|
|
137
|
+
const p = plans.find((x) => x.key === key);
|
|
138
|
+
if (p.action === 'unchanged') continue;
|
|
139
|
+
await mkdir(dirname(p.path), { recursive: true });
|
|
140
|
+
// The renderer takes the plan because detection-driven content is a function of
|
|
141
|
+
// this repo's facts: a dependabot config rendered without them would watch
|
|
142
|
+
// nothing, which is the same silent-nothing the detection ruling exists to stop.
|
|
143
|
+
await writeFile(p.path, render(p));
|
|
144
|
+
note(`wrote ${p.file} (${p.action}; the template and this copy now agree)`);
|
|
145
|
+
}
|
|
146
|
+
if (isJson(flags)) {
|
|
147
|
+
emitJson(
|
|
148
|
+
{
|
|
149
|
+
ok: true,
|
|
150
|
+
command: 'ci',
|
|
151
|
+
root,
|
|
152
|
+
files: plans.map((p) => ({ file: p.file, action: p.action, bytes: p.bytes })),
|
|
153
|
+
written: true,
|
|
154
|
+
},
|
|
155
|
+
{},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return EXIT.OK;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The refusal, which is a report rather than a wall: a reader who ran this in a
|
|
163
|
+
* repository that adopted simt-dev has almost certainly just been told to run
|
|
164
|
+
* something that does not exist, and the useful answer is which command does exist.
|
|
165
|
+
*/
|
|
166
|
+
function failOutside(root, plan, flags) {
|
|
167
|
+
const remedy = `${CLI} init --yes (a repository's .github files are written once, by init, and then left alone)`;
|
|
168
|
+
if (isJson(flags)) {
|
|
169
|
+
emitJson(
|
|
170
|
+
{
|
|
171
|
+
// `ok: true` with a refusal in it was the shape this whole command had: the
|
|
172
|
+
// document said the request succeeded while the text said it was refused, and
|
|
173
|
+
// a caller reading `ok` had no way to know which one the writer meant. It is
|
|
174
|
+
// also `EXIT.UNSUPPORTED` rather than 0 for the same reason — the request
|
|
175
|
+
// cannot be satisfied in this repository, and that is a fact about the
|
|
176
|
+
// repository, not a successful no-op.
|
|
177
|
+
ok: false,
|
|
178
|
+
command: 'ci',
|
|
179
|
+
root,
|
|
180
|
+
file: plan.file,
|
|
181
|
+
action: plan.action,
|
|
182
|
+
own: false,
|
|
183
|
+
error: `simt-dev refreshes its own .github files; ${plan.file} belongs to this repository`,
|
|
184
|
+
remedy,
|
|
185
|
+
written: false,
|
|
186
|
+
},
|
|
187
|
+
{},
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
emit(
|
|
191
|
+
[
|
|
192
|
+
`file ${plan.file}`,
|
|
193
|
+
`state ${plan.exists ? 'present' : 'absent'}`,
|
|
194
|
+
`owner this repository, not simt-dev`,
|
|
195
|
+
'',
|
|
196
|
+
'simt-dev refreshes its own .github files and no one else\'s. This command is not',
|
|
197
|
+
'how a repository that adopted simt-dev gets one; `init` is, and it writes the',
|
|
198
|
+
'file once.',
|
|
199
|
+
].join('\n'),
|
|
200
|
+
);
|
|
201
|
+
note('');
|
|
202
|
+
note(`remedy: ${remedy}`);
|
|
203
|
+
}
|
|
204
|
+
return EXIT.UNSUPPORTED;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A plan without the file bodies. Both sides of the comparison travel on the plan so
|
|
209
|
+
* the verdict and the diff cannot disagree, and a JSON payload is not the place for
|
|
210
|
+
* two copies of a workflow file.
|
|
211
|
+
*/
|
|
212
|
+
function publicPlan({ source, ...plan }) {
|
|
213
|
+
return plan;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A unified diff of the two, or `''` when they are identical.
|
|
218
|
+
*
|
|
219
|
+
* Small on purpose: the reader is the author of the template change that made the
|
|
220
|
+
* copy stale, and the question is "what did I change". Line-level LCS rather than a
|
|
221
|
+
* character diff, because a workflow's drift is a moved step, not a typo — and a
|
|
222
|
+
* table over tens of lines is smaller to read and harder to get wrong than a Myers
|
|
223
|
+
* scan. No dependency, per ADR-012.
|
|
224
|
+
*/
|
|
225
|
+
function unified(file, current, desired) {
|
|
226
|
+
const a = (current ?? '').split('\n');
|
|
227
|
+
const b = (desired ?? '').split('\n');
|
|
228
|
+
const table = Array.from({ length: a.length + 1 }, () => new Uint32Array(b.length + 1));
|
|
229
|
+
for (let i = a.length - 1; i >= 0; i -= 1) {
|
|
230
|
+
for (let j = b.length - 1; j >= 0; j -= 1) {
|
|
231
|
+
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const rows = [];
|
|
235
|
+
let i = 0;
|
|
236
|
+
let j = 0;
|
|
237
|
+
while (i < a.length || j < b.length) {
|
|
238
|
+
if (i < a.length && j < b.length && a[i] === b[j]) {
|
|
239
|
+
rows.push({ mark: ' ', line: a[i] });
|
|
240
|
+
i += 1;
|
|
241
|
+
j += 1;
|
|
242
|
+
} else if (j < b.length && (i === a.length || table[i][j + 1] >= table[i + 1][j])) {
|
|
243
|
+
rows.push({ mark: '+', line: b[j] });
|
|
244
|
+
j += 1;
|
|
245
|
+
} else {
|
|
246
|
+
rows.push({ mark: '-', line: a[i] });
|
|
247
|
+
i += 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// A diff of nothing is not a diff. Returning '' is what lets the caller decide,
|
|
251
|
+
// rather than printing a header over an empty body.
|
|
252
|
+
if (rows.every((r) => r.mark === ' ')) return '';
|
|
253
|
+
|
|
254
|
+
// Three lines of context, then `...` for whatever was elided. Printing all fifty
|
|
255
|
+
// lines of a workflow to report one changed step buries the answer in context, and
|
|
256
|
+
// the answer is the only reason this exists.
|
|
257
|
+
const CONTEXT = 3;
|
|
258
|
+
const keep = new Array(rows.length).fill(false);
|
|
259
|
+
rows.forEach((r, at) => {
|
|
260
|
+
if (r.mark === ' ') return;
|
|
261
|
+
for (let k = Math.max(0, at - CONTEXT); k <= Math.min(rows.length - 1, at + CONTEXT); k += 1) {
|
|
262
|
+
keep[k] = true;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
const out = [];
|
|
266
|
+
let elided = 0;
|
|
267
|
+
for (const [at, r] of rows.entries()) {
|
|
268
|
+
if (!keep[at]) {
|
|
269
|
+
elided += 1;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (elided > 0) {
|
|
273
|
+
out.push(`@@ ${elided} unchanged line(s) elided`);
|
|
274
|
+
elided = 0;
|
|
275
|
+
}
|
|
276
|
+
out.push(`${r.mark}${r.line}`);
|
|
277
|
+
}
|
|
278
|
+
const changed = rows.filter((r) => r.mark !== ' ').length;
|
|
279
|
+
return [
|
|
280
|
+
`--- a/${file}`,
|
|
281
|
+
`+++ b/${file}`,
|
|
282
|
+
`@@ ${changed} changed line(s); apply with: ${CLI} ci --yes @@`,
|
|
283
|
+
...out,
|
|
284
|
+
].join('\n');
|
|
285
|
+
}
|