redlinegate 0.0.1 → 0.0.3
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/README.md +23 -7
- package/dist/bin/redline.js +338 -43
- package/dist/bin/redline.js.map +1 -1
- package/dist/commands/init.js +353 -34
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/remove.js +43 -1
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/status.js +94 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/verify.js +82 -2
- package/dist/commands/verify.js.map +1 -1
- package/dist/config/redline-json.js +63 -2
- package/dist/config/redline-json.js.map +1 -1
- package/dist/core/git.js +50 -3
- package/dist/core/git.js.map +1 -1
- package/dist/core/version.js +6 -0
- package/dist/core/version.js.map +1 -1
- package/dist/detect/existing.js +151 -0
- package/dist/detect/existing.js.map +1 -0
- package/dist/detect/setup.js +200 -0
- package/dist/detect/setup.js.map +1 -0
- package/dist/detect/stack.js +56 -17
- package/dist/detect/stack.js.map +1 -1
- package/dist/exempt/parse.js +8 -1
- package/dist/exempt/parse.js.map +1 -1
- package/dist/metrics/options.js +0 -9
- package/dist/metrics/options.js.map +1 -1
- package/dist/platforms/azure/install.js +9 -9
- package/dist/platforms/azure/install.js.map +1 -1
- package/dist/platforms/azure/verify.js +10 -2
- package/dist/platforms/azure/verify.js.map +1 -1
- package/dist/platforms/github/install.js +192 -26
- package/dist/platforms/github/install.js.map +1 -1
- package/dist/platforms/github/preflight.js +63 -0
- package/dist/platforms/github/preflight.js.map +1 -0
- package/dist/platforms/github/vendor.js +81 -0
- package/dist/platforms/github/vendor.js.map +1 -0
- package/dist/platforms/github/verify.js +79 -7
- package/dist/platforms/github/verify.js.map +1 -1
- package/dist/platforms/types.js +8 -0
- package/dist/platforms/types.js.map +1 -1
- package/dist/policy/diff.js +26 -9
- package/dist/policy/diff.js.map +1 -1
- package/dist/render/contexts.js +39 -0
- package/dist/render/contexts.js.map +1 -0
- package/dist/render/profile.js +44 -10
- package/dist/render/profile.js.map +1 -1
- package/dist/render/standards.js +10 -1
- package/dist/render/standards.js.map +1 -1
- package/dist/render/vendors.js +14 -53
- package/dist/render/vendors.js.map +1 -1
- package/dist/rules/catalogue.js +127 -0
- package/dist/rules/catalogue.js.map +1 -0
- package/dist/ui/facts.js +111 -0
- package/dist/ui/facts.js.map +1 -0
- package/dist/ui/prompt.js +330 -0
- package/dist/ui/prompt.js.map +1 -0
- package/dist/ui/report.js +214 -0
- package/dist/ui/report.js.map +1 -0
- package/dist/ui/tty.js +576 -0
- package/dist/ui/tty.js.map +1 -0
- package/dist/ui/wizard.js +293 -0
- package/dist/ui/wizard.js.map +1 -0
- package/dist/verify/remote.js +33 -0
- package/dist/verify/remote.js.map +1 -1
- package/package.json +13 -1
- package/platforms/azure/gate-template-github.yml +152 -0
- package/platforms/azure/gate-template.yml +59 -6
- package/scripts/check-pins.mjs +84 -1
- package/scripts/fetch-stars.mjs +92 -0
- package/scripts/lib/rules.d.mts +18 -0
- package/scripts/publish-local.mjs +183 -0
- package/standards/contexts/speckit.md +22 -0
- package/standards/contexts/tmf.md +25 -0
- package/standards/manifest.json +78 -7
- package/standards/stacks/angular.md +57 -0
- package/standards/stacks/dom.md +61 -0
- package/standards/stacks/svelte.md +45 -0
- package/standards/stacks/vue.md +54 -0
- package/templates/redline.yml +9 -9
- package/workflows/dashboard.yml +1 -1
- package/workflows/redline-collect.yml +1 -1
- package/workflows/redline-gate.yml +38 -7
- package/workflows/seed-canary.yml +2 -2
- package/workflows/weekly-digest.yml +1 -1
- package/scripts/measure-context.mjs +0 -101
|
@@ -22,6 +22,18 @@ variables:
|
|
|
22
22
|
ADR_DIFF_THRESHOLD: 300
|
|
23
23
|
FAIL_ON_DEPENDENCY_SEVERITY: high
|
|
24
24
|
SOFT_FAIL_LABELS: redline-exempt,redline-sync
|
|
25
|
+
# Declared with a default so the status step can read it even when the scan
|
|
26
|
+
# passed and never set it. An undeclared Azure variable interpolates as the
|
|
27
|
+
# literal string "$(NAME)" rather than empty, which is the kind of quiet
|
|
28
|
+
# wrong answer this gate exists to prevent.
|
|
29
|
+
REDLINE_SECURITY_FAILED: 'false'
|
|
30
|
+
# Pinned by image digest, not by tag: a tag is mutable and this scans the diff
|
|
31
|
+
# of every pull request in the repository. The version in the trailing comment
|
|
32
|
+
# is what scripts/check-pins.mjs re-resolves the digest against, exactly as it
|
|
33
|
+
# does for the SHA-pinned actions on the GitHub side of the gate. Docker Hub
|
|
34
|
+
# spells the tag without the `v` the git tag carries; both sides of the gate
|
|
35
|
+
# deliberately run the same version.
|
|
36
|
+
TRUFFLEHOG_IMAGE: trufflesecurity/trufflehog@sha256:deb2af10659a488a14d262a323addcde099d99827a1cf1dc4e93c17915c39f08 # 3.97.1
|
|
25
37
|
|
|
26
38
|
steps:
|
|
27
39
|
- checkout: self
|
|
@@ -32,12 +44,48 @@ steps:
|
|
|
32
44
|
inputs:
|
|
33
45
|
versionSpec: '22.x'
|
|
34
46
|
|
|
47
|
+
# The GitHub gate runs this as its own job (workflows/redline-gate.yml,
|
|
48
|
+
# `secrets`). It has no substitute in Mend or SonarQube: both scan a state of
|
|
49
|
+
# the tree, and a credential committed and reverted inside one pull request
|
|
50
|
+
# is still in the history the moment it merges. This scans the RANGE.
|
|
51
|
+
#
|
|
52
|
+
# `--results=verified` only, matching the GitHub job for the same reason:
|
|
53
|
+
# `unknown` produces false positives and this check cannot be waived.
|
|
54
|
+
- script: |
|
|
55
|
+
set -euo pipefail
|
|
56
|
+
target="${SYSTEM_PULLREQUEST_TARGETBRANCH#refs/heads/}"
|
|
57
|
+
# The PR build checks out a merge commit, and the target branch is not
|
|
58
|
+
# guaranteed to be a local ref on a fresh agent. Fetch it by name before
|
|
59
|
+
# asking for a merge base, or `git merge-base` fails and the scan silently
|
|
60
|
+
# degrades to scanning nothing.
|
|
61
|
+
git fetch --no-tags origin "$target"
|
|
62
|
+
base=$(git merge-base FETCH_HEAD HEAD)
|
|
63
|
+
echo "scanning $base..HEAD"
|
|
64
|
+
|
|
65
|
+
# The failure is recorded BEFORE the exit, because the exemption branch in
|
|
66
|
+
# the status step below must never downgrade this one. On GitHub the
|
|
67
|
+
# security jobs sit outside label exemption; this variable is how that
|
|
68
|
+
# exclusion survives the two being one pipeline here.
|
|
69
|
+
if ! docker run --rm -v "$(pwd):/repo" "$TRUFFLEHOG_IMAGE" \
|
|
70
|
+
git file:///repo --since-commit "$base" --results=verified --fail
|
|
71
|
+
then
|
|
72
|
+
echo "##vso[task.setvariable variable=REDLINE_SECURITY_FAILED]true"
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
displayName: Secret scan (diff)
|
|
76
|
+
name: secrets
|
|
77
|
+
env:
|
|
78
|
+
TRUFFLEHOG_IMAGE: $(TRUFFLEHOG_IMAGE)
|
|
79
|
+
|
|
35
80
|
# --package pins npx to the redlinegate package explicitly. `redline` alone
|
|
36
81
|
# names a different, unrelated package on the public registry — `redline`
|
|
37
82
|
# is only this package's bin name, never resolve npx against it directly.
|
|
38
83
|
- script: npx --yes --package=redlinegate@latest redline verify --gate
|
|
39
84
|
displayName: Redline gate
|
|
40
85
|
name: gate
|
|
86
|
+
# A failed secret scan must not hide the configuration findings: the author
|
|
87
|
+
# deserves the whole list in one run rather than one blocker at a time.
|
|
88
|
+
condition: succeededOrFailed()
|
|
41
89
|
env:
|
|
42
90
|
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
|
43
91
|
ADR_DIFF_THRESHOLD: $(ADR_DIFF_THRESHOLD)
|
|
@@ -72,12 +120,16 @@ steps:
|
|
|
72
120
|
# accept a process failure deliberately. It can only ever turn a
|
|
73
121
|
# failure into a success, never the reverse.
|
|
74
122
|
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
|
|
123
|
+
# The secret scan is excluded, exactly as it is on GitHub, where the
|
|
124
|
+
# security jobs sit outside the exemption. There the exclusion is
|
|
125
|
+
# structural — separate jobs, and the label only reaches some of them.
|
|
126
|
+
# Here the whole gate is one pipeline, so the scan records
|
|
127
|
+
# REDLINE_SECURITY_FAILED before it exits and this branch refuses to
|
|
128
|
+
# downgrade it. A label that could waive a verified credential in the
|
|
129
|
+
# diff would make the label the vulnerability.
|
|
130
|
+
if [ "${REDLINE_SECURITY_FAILED:-}" = "true" ]; then
|
|
131
|
+
echo "##vso[task.logissue type=error]The secret scan failed. This check cannot be waived by a label."
|
|
132
|
+
elif [ "$state" = "failed" ] && [ -n "${SOFT_FAIL_LABELS:-}" ]; then
|
|
81
133
|
labels=$(curl_authed -sS "$pr_url/labels?api-version=7.1") || labels=''
|
|
82
134
|
for label in ${SOFT_FAIL_LABELS//,/ }; do
|
|
83
135
|
if printf '%s' "$labels" \
|
|
@@ -103,3 +155,4 @@ steps:
|
|
|
103
155
|
env:
|
|
104
156
|
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
|
105
157
|
SOFT_FAIL_LABELS: $(SOFT_FAIL_LABELS)
|
|
158
|
+
REDLINE_SECURITY_FAILED: $(REDLINE_SECURITY_FAILED)
|
package/scripts/check-pins.mjs
CHANGED
|
@@ -39,6 +39,14 @@ const files = [
|
|
|
39
39
|
? readdirSync(join(ROOT, '.github/workflows')).map((f) => `.github/workflows/${f}`)
|
|
40
40
|
: []),
|
|
41
41
|
'templates/redline.yml',
|
|
42
|
+
// The Azure gate. It was outside this list while it pinned nothing, and the
|
|
43
|
+
// moment it pinned a container digest that omission would have made the pin
|
|
44
|
+
// unverifiable — the exact rot this script exists to catch.
|
|
45
|
+
'platforms/azure/gate-template.yml',
|
|
46
|
+
// The same gate for a GitHub-hosted repository built by Azure Pipelines. It
|
|
47
|
+
// carries the same container digest, so leaving it out here would let the two
|
|
48
|
+
// halves of the same gate drift to different versions of the scanner.
|
|
49
|
+
'platforms/azure/gate-template-github.yml',
|
|
42
50
|
].filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
43
51
|
|
|
44
52
|
const pins = [];
|
|
@@ -84,5 +92,80 @@ for (const pin of pins) {
|
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
87
|
-
|
|
95
|
+
// The Azure gate runs its secret scan from a container rather than an action,
|
|
96
|
+
// so the pin is an image digest with the version in a trailing comment — the
|
|
97
|
+
// same shape as `uses: repo@sha # tag`, verified the same way. A digest that no
|
|
98
|
+
// longer matches the tag it claims means someone edited one and not the other,
|
|
99
|
+
// and the scan of every Azure pull request is then running an unreviewed image.
|
|
100
|
+
const IMAGE_PIN = /([\w.-]+\/[\w.-]+)@(sha256:[0-9a-f]{64})\s*#\s*(\S+)/g;
|
|
101
|
+
const imagePins = [];
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const body = readFileSync(join(ROOT, file), 'utf8');
|
|
104
|
+
for (const [, image, digest, tag] of body.matchAll(IMAGE_PIN)) {
|
|
105
|
+
imagePins.push({ file, image, digest, tag });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const pin of imagePins) {
|
|
110
|
+
try {
|
|
111
|
+
const res = await fetch(`https://hub.docker.com/v2/repositories/${pin.image}/tags/${pin.tag}`);
|
|
112
|
+
if (!res.ok) throw new Error(`docker hub: ${res.status}`);
|
|
113
|
+
const resolved = (await res.json()).digest;
|
|
114
|
+
if (resolved !== pin.digest) {
|
|
115
|
+
console.error(
|
|
116
|
+
`FAIL ${pin.file}: ${pin.image}@${pin.digest} is commented as ${pin.tag}, but ${pin.tag} resolves to ${resolved}`
|
|
117
|
+
);
|
|
118
|
+
errors += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
console.log(`ok ${pin.image}:${pin.tag} → ${pin.digest.slice(7, 19)}`);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`FAIL ${pin.file}: could not verify ${pin.image}:${pin.tag} — ${err.message}`);
|
|
124
|
+
errors += 1;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The gate also pins the CLI it shells out to, as `REDLINE_CLI_VERSION` rather
|
|
129
|
+
// than as a `uses:` SHA, so the loop above cannot see it. It is the pin that
|
|
130
|
+
// rots hardest: it is a plain literal in two places, nothing in the release
|
|
131
|
+
// wires it to a publish, and until someone edits it by hand a fix shipped in
|
|
132
|
+
// the CLI reaches no onboarded repository at all — the gate keeps running the
|
|
133
|
+
// version named here. A published version behind the latest is reported the
|
|
134
|
+
// same way a stale action tag is.
|
|
135
|
+
const CLI_PIN = /REDLINE_CLI_VERSION:\s*'([^']+)'/g;
|
|
136
|
+
const cliPins = [];
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
const body = readFileSync(join(ROOT, file), 'utf8');
|
|
139
|
+
for (const [, version] of body.matchAll(CLI_PIN)) cliPins.push({ file, version });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (cliPins.length) {
|
|
143
|
+
const pinned = [...new Set(cliPins.map((p) => p.version))];
|
|
144
|
+
if (pinned.length > 1) {
|
|
145
|
+
console.error(
|
|
146
|
+
`FAIL REDLINE_CLI_VERSION disagrees with itself: ${pinned.join(', ')} — every job in one gate must run the same CLI`
|
|
147
|
+
);
|
|
148
|
+
errors += 1;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const res = await fetch('https://registry.npmjs.org/redlinegate');
|
|
152
|
+
if (!res.ok) throw new Error(`registry: ${res.status}`);
|
|
153
|
+
const latest = (await res.json())['dist-tags']?.latest;
|
|
154
|
+
for (const version of pinned) {
|
|
155
|
+
if (latest && latest !== version) {
|
|
156
|
+
console.warn(` update available: redlinegate@${version} → ${latest}`);
|
|
157
|
+
updates += 1;
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`ok redlinegate@${version} is the published latest`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`FAIL could not check redlinegate against the registry — ${err.message}`);
|
|
164
|
+
errors += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(
|
|
169
|
+
`\n${pins.length + cliPins.length + imagePins.length} pin(s), ${errors} error(s), ${updates} update(s) available`
|
|
170
|
+
);
|
|
88
171
|
process.exit(errors || (STRICT && updates) ? 1 : 0);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Refreshes web/lib/github-stars.json.
|
|
4
|
+
*
|
|
5
|
+
* Star counts drift. Rather than pretend otherwise we snapshot them, record the
|
|
6
|
+
* date the snapshot was taken, and render that date next to the number. The
|
|
7
|
+
* build never calls the network: if this file is stale, the site says so; if a
|
|
8
|
+
* repo is missing from it, the site omits the count for that repo rather than
|
|
9
|
+
* guessing.
|
|
10
|
+
*
|
|
11
|
+
* Run: node scripts/fetch-stars.mjs (needs an authenticated `gh`)
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
14
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
|
|
17
|
+
const run = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
const SOURCE = new URL("../web/lib/skills-catalog.ts", import.meta.url);
|
|
20
|
+
const OUT = new URL("../web/lib/github-stars.json", import.meta.url);
|
|
21
|
+
|
|
22
|
+
/** Pull every `owner`/`repo` pair out of the PUBLISHERS table. */
|
|
23
|
+
function repoSlugs(src) {
|
|
24
|
+
const re = /owner:\s*"([^"]+)",\s*\n\s*repo:\s*"([^"]+)"/g;
|
|
25
|
+
const slugs = new Set();
|
|
26
|
+
for (const m of src.matchAll(re)) slugs.add(`${m[1]}/${m[2]}`);
|
|
27
|
+
return [...slugs].sort();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function stars(slug) {
|
|
31
|
+
// execFile, not exec: `slug` goes in as one argv entry, never through a shell.
|
|
32
|
+
const { stdout } = await run("gh", [
|
|
33
|
+
"api",
|
|
34
|
+
`repos/${slug}`,
|
|
35
|
+
"--jq",
|
|
36
|
+
".stargazers_count",
|
|
37
|
+
]);
|
|
38
|
+
const n = Number.parseInt(stdout.trim(), 10);
|
|
39
|
+
if (!Number.isFinite(n)) throw new Error(`unparseable count for ${slug}`);
|
|
40
|
+
return n;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const src = await readFile(SOURCE, "utf8");
|
|
44
|
+
const slugs = repoSlugs(src);
|
|
45
|
+
if (slugs.length === 0) {
|
|
46
|
+
console.error("No owner/repo pairs found — did PUBLISHERS move?");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const previous = await readFile(OUT, "utf8").then(
|
|
51
|
+
(t) => JSON.parse(t).stars ?? {},
|
|
52
|
+
() => ({}),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const next = {};
|
|
56
|
+
let failed = 0;
|
|
57
|
+
for (const slug of slugs) {
|
|
58
|
+
try {
|
|
59
|
+
next[slug] = await stars(slug);
|
|
60
|
+
console.log(` ${String(next[slug]).padStart(7)} ${slug}`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
failed += 1;
|
|
63
|
+
// Drop the repo rather than carry the previous run's number forward. Every
|
|
64
|
+
// value in this file is stamped with one shared fetchedAt, so a carried
|
|
65
|
+
// number would be published under a date on which nobody counted it —
|
|
66
|
+
// the exact misstatement the dated snapshot exists to prevent. A repo with
|
|
67
|
+
// no number simply renders without the stars fact, which is honest.
|
|
68
|
+
console.warn(` failed: ${slug} — ${err.message.split("\n")[0]}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (failed === slugs.length) {
|
|
73
|
+
console.error("Every request failed; leaving the existing snapshot alone.");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const payload = {
|
|
78
|
+
fetchedAt: new Date().toISOString().slice(0, 10),
|
|
79
|
+
stars: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const dropped = Object.keys(previous).filter((slug) => !(slug in next));
|
|
83
|
+
if (dropped.length) {
|
|
84
|
+
console.warn(
|
|
85
|
+
`\n${dropped.length} repo(s) in the previous snapshot have no number this run ` +
|
|
86
|
+
`and were dropped rather than re-dated: ${dropped.join(", ")}.\n` +
|
|
87
|
+
`Re-run to restore them; their pages render without a stars fact meanwhile.`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await writeFile(OUT, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
92
|
+
console.log(`\nWrote ${Object.keys(payload.stars).length} repos to lib/github-stars.json`);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Types for the CI rule parser, so cli/rules/__tests__/catalogue.test.ts can hold
|
|
2
|
+
// the CLI's own reader against it without an untyped import. The runtime module
|
|
3
|
+
// stays plain .mjs: it is run by node directly, never built.
|
|
4
|
+
export interface ScriptRule {
|
|
5
|
+
id: string;
|
|
6
|
+
stack: string;
|
|
7
|
+
severity: 'BLOCKER' | 'HIGH' | 'SUGGESTION';
|
|
8
|
+
text: string;
|
|
9
|
+
source: string;
|
|
10
|
+
line: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export declare const ROOT: string;
|
|
14
|
+
export declare const SEVERITIES: readonly string[];
|
|
15
|
+
export declare const RANK: Record<string, number>;
|
|
16
|
+
export declare const RULE_ID: RegExp;
|
|
17
|
+
export declare const RESERVED_RULE_IDS: ReadonlySet<string>;
|
|
18
|
+
export declare function loadRules(root?: string): Map<string, ScriptRule>;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Build, pack and install this CLI globally, so `redline` on PATH is the working tree.
|
|
3
|
+
//
|
|
4
|
+
// The whole reason this is a script and not a one-line npm chain: which `npm` runs
|
|
5
|
+
// the global install decides whether the install is visible at all.
|
|
6
|
+
//
|
|
7
|
+
// Under pnpm, `npm` resolves to ./node_modules/.bin/npm — a locally installed npm
|
|
8
|
+
// that is not shimmed by Volta. Its `npm i -g` writes into its own global prefix,
|
|
9
|
+
// while the `redline` command on PATH is a Volta shim pointing at Volta's package
|
|
10
|
+
// image. The install reports success, the tarball is correct, and the binary you
|
|
11
|
+
// run is still the old one. That failure is silent and costs an hour.
|
|
12
|
+
//
|
|
13
|
+
// So: resolve npm explicitly, never through a node_modules/.bin that a package
|
|
14
|
+
// manager put in front, and verify afterwards that the binary on PATH is actually
|
|
15
|
+
// the build we just made.
|
|
16
|
+
|
|
17
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
// Present only in a current build: the check at the end greps the installed
|
|
25
|
+
// binary's own help for it.
|
|
26
|
+
const MARKER = 'redline status';
|
|
27
|
+
|
|
28
|
+
const run = (file, args, opts = {}) =>
|
|
29
|
+
execFileSync(file, args, { cwd: ROOT, stdio: 'inherit', ...opts });
|
|
30
|
+
|
|
31
|
+
// Volta sets _VOLTA_TOOL_RECURSION on anything it launches, and its shims skip
|
|
32
|
+
// their own hook when they see it — sensible, since it stops a tool Volta ran
|
|
33
|
+
// from re-entering Volta forever. It also means a global install performed from
|
|
34
|
+
// inside a script Volta started never updates the shim: the files land, npm says
|
|
35
|
+
// "added 1 package", and the `redline` on PATH keeps running the old build. That
|
|
36
|
+
// single variable is why every publish this session appeared to succeed and
|
|
37
|
+
// changed nothing. Clearing it for the install restores the behaviour of typing
|
|
38
|
+
// the command in a shell.
|
|
39
|
+
const withoutVoltaGuard = () => {
|
|
40
|
+
const env = { ...process.env };
|
|
41
|
+
delete env['_VOLTA_TOOL_RECURSION'];
|
|
42
|
+
return env;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const quiet = (command) => {
|
|
46
|
+
try {
|
|
47
|
+
return execSync(command, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
48
|
+
} catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The npm that a global install has to go through.
|
|
55
|
+
*
|
|
56
|
+
* Under Volta this is `~/.volta/bin/npm` — the SHIM. Not `volta which npm`,
|
|
57
|
+
* which returns the image binary underneath it: installing through that writes
|
|
58
|
+
* the package into the node image and never triggers the hook that builds the
|
|
59
|
+
* package image the `redline` shim actually executes. The install reports
|
|
60
|
+
* success, the files land, and the command keeps running the old build.
|
|
61
|
+
*
|
|
62
|
+
* Then any npm on PATH that is not inside a node_modules — that is one a package
|
|
63
|
+
* manager shadowed, as pnpm does with the npm semantic-release depends on.
|
|
64
|
+
*/
|
|
65
|
+
function resolveNpm() {
|
|
66
|
+
const shim = join(process.env.HOME ?? '', '.volta', 'bin', 'npm');
|
|
67
|
+
if (existsSync(shim)) return shim;
|
|
68
|
+
|
|
69
|
+
for (const dir of (process.env.PATH ?? '').split(':')) {
|
|
70
|
+
if (dir === '' || dir.includes('node_modules')) continue;
|
|
71
|
+
const candidate = join(dir, 'npm');
|
|
72
|
+
if (existsSync(candidate)) return candidate;
|
|
73
|
+
}
|
|
74
|
+
return 'npm';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `redline` a person actually gets when they type it.
|
|
79
|
+
*
|
|
80
|
+
* PATH first. Falling back to Volta's shim matters because a non-interactive
|
|
81
|
+
* shell does not always carry ~/.volta/bin, and reporting "not installed" to
|
|
82
|
+
* somebody whose terminal runs it perfectly well is worse than useless.
|
|
83
|
+
*/
|
|
84
|
+
function liveBinary() {
|
|
85
|
+
// The shim first, when there is one. It is what a person's interactive shell
|
|
86
|
+
// runs, and it is the copy that goes stale — `command -v redline` in a
|
|
87
|
+
// non-interactive shell can easily resolve to a different, fresher copy and
|
|
88
|
+
// report success for a binary nobody types.
|
|
89
|
+
const shim = join(process.env.HOME ?? '', '.volta', 'bin', 'redline');
|
|
90
|
+
if (existsSync(shim)) return shim;
|
|
91
|
+
return quiet('command -v redline') !== '' ? 'redline' : '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const npm = resolveNpm();
|
|
95
|
+
console.log(` using npm at ${npm}`);
|
|
96
|
+
|
|
97
|
+
// A unique version per publish, and this is the load-bearing part.
|
|
98
|
+
//
|
|
99
|
+
// package.json carries 0.0.0-development permanently — semantic-release sets the
|
|
100
|
+
// real version at publish time. Both npm and Volta key a global package by
|
|
101
|
+
// name@version, so every local publish looked identical to the last one and was
|
|
102
|
+
// served from cache: the tarball was rebuilt, the install reported success, and
|
|
103
|
+
// Volta restored the SAME old package image behind the shim. Each publish was
|
|
104
|
+
// actively downgrading the binary back to whatever was cached first.
|
|
105
|
+
//
|
|
106
|
+
// Stamping a timestamp version defeats both caches, and has a second use: the
|
|
107
|
+
// version `redline --version` prints is now the moment it was installed, so a
|
|
108
|
+
// stale binary is visible without any of this archaeology.
|
|
109
|
+
const manifestPath = join(ROOT, 'package.json');
|
|
110
|
+
const original = readFileSync(manifestPath, 'utf8');
|
|
111
|
+
const manifest = JSON.parse(original);
|
|
112
|
+
const localVersion = `0.0.0-local.${Math.floor(Date.now() / 1000)}`;
|
|
113
|
+
const tarball = `redlinegate-${localVersion}.tgz`;
|
|
114
|
+
|
|
115
|
+
// Clear Volta's entry first, and before anything else.
|
|
116
|
+
//
|
|
117
|
+
// Volta keeps its own copy of a global package — the one its shim runs — apart
|
|
118
|
+
// from the node image npm writes to. While it holds an entry, `npm i -g` updates
|
|
119
|
+
// only the node image and the shim keeps executing the old code. Order matters:
|
|
120
|
+
// clearing it after the pack leaves the shim deleted and not recreated, which is
|
|
121
|
+
// how `redline` kept vanishing from PATH.
|
|
122
|
+
const hasVolta = quiet('volta --version') !== '';
|
|
123
|
+
if (hasVolta) {
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('volta', ['uninstall', 'redlinegate'], {
|
|
126
|
+
stdio: 'ignore',
|
|
127
|
+
env: withoutVoltaGuard(),
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// Not installed through Volta. Nothing to clear.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
writeFileSync(
|
|
136
|
+
manifestPath,
|
|
137
|
+
JSON.stringify({ ...manifest, version: localVersion }, null, 2) + '\n',
|
|
138
|
+
'utf8'
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
run(npm, ['run', 'build']);
|
|
142
|
+
run(npm, ['pack']);
|
|
143
|
+
|
|
144
|
+
run(npm, ['i', '-g', `./${tarball}`], { env: withoutVoltaGuard() });
|
|
145
|
+
} finally {
|
|
146
|
+
// The real version is semantic-release's to set. Restoring it has to survive a
|
|
147
|
+
// failed build, or a crash here leaves a timestamp version committed.
|
|
148
|
+
writeFileSync(manifestPath, original, 'utf8');
|
|
149
|
+
rmSync(join(ROOT, tarball), { force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify by RUNNING both and comparing what they print.
|
|
153
|
+
//
|
|
154
|
+
// Every other way of checking this was wrong at least once. A marker string in
|
|
155
|
+
// --help stays true across builds, so it passed while the installed copy was
|
|
156
|
+
// three changes behind. Comparing files means guessing which of the copies the
|
|
157
|
+
// command resolves to — and under Volta there are two, in different places,
|
|
158
|
+
// only one of which the shim executes. Executing it answers the only question
|
|
159
|
+
// that matters: does the command a person types run the code just built.
|
|
160
|
+
const binary = liveBinary();
|
|
161
|
+
if (binary === '') {
|
|
162
|
+
console.error('\n redline is not on PATH after installing. Check your shell PATH.\n');
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const live = quiet(`${binary} --help`);
|
|
167
|
+
const built = quiet(`node "${join(ROOT, 'dist', 'bin', 'redline.js')}" --help`);
|
|
168
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex').slice(0, 12);
|
|
169
|
+
|
|
170
|
+
if (live === '' || built === '') {
|
|
171
|
+
console.error('\n Could not run redline to compare builds.\n');
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
if (live !== built) {
|
|
175
|
+
console.error('\n The installed redline is STALE: it runs older code than this build.');
|
|
176
|
+
console.error(` installed ${digest(live)} built ${digest(built)}`);
|
|
177
|
+
console.error('\n Under Volta the package image is cached by name@version, and this version');
|
|
178
|
+
console.error(' never changes. Clear it and install again:');
|
|
179
|
+
console.error(' volta uninstall redlinegate');
|
|
180
|
+
console.error(' ~/.volta/bin/npm i -g ./redlinegate-<version>.tgz\n');
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
console.log(` installed redline runs this build (${digest(built)})`);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Context: spec-driven development
|
|
2
|
+
|
|
3
|
+
This repository works spec-first. A change begins as a specification, becomes a
|
|
4
|
+
plan, becomes tasks, and only then becomes code. Treat the specification as the
|
|
5
|
+
statement of intent that the diff is measured against.
|
|
6
|
+
|
|
7
|
+
When reviewing:
|
|
8
|
+
|
|
9
|
+
- Measure the diff against the specification it claims to implement, not against
|
|
10
|
+
what the code appears to be trying to do. Code that works and does something
|
|
11
|
+
the spec does not ask for is still a finding — say which section it departs
|
|
12
|
+
from.
|
|
13
|
+
- A change with no specification is not automatically wrong. Trivial fixes,
|
|
14
|
+
dependency bumps and revert commits do not need one. A new capability does.
|
|
15
|
+
- Where the specification and the code disagree, the specification is not
|
|
16
|
+
automatically right either. Say which one you believe is wrong and why, rather
|
|
17
|
+
than silently assuming the text wins.
|
|
18
|
+
- Do not restate the specification back to the author. They wrote it.
|
|
19
|
+
|
|
20
|
+
Spec Kit is a separate tool with its own installer and its own templates.
|
|
21
|
+
Redline does not create, edit or version its files; this section only tells a
|
|
22
|
+
reviewer that the repository works this way.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Context: TM Forum
|
|
2
|
+
|
|
3
|
+
This repository implements TM Forum interfaces. The published Open API and ODA
|
|
4
|
+
specifications are part of the contract, and a consumer integrating against them
|
|
5
|
+
is entitled to what those documents describe.
|
|
6
|
+
|
|
7
|
+
When reviewing:
|
|
8
|
+
|
|
9
|
+
- Resource and field names follow the published TMF API for the domain. A field
|
|
10
|
+
renamed for local convenience breaks every consumer generated from the spec.
|
|
11
|
+
- Polymorphism carries `@type`, and `@baseType` / `@schemaLocation` where the
|
|
12
|
+
API defines them. Dropping them because "nothing reads it here" removes the
|
|
13
|
+
discriminator a consumer needs.
|
|
14
|
+
- Collections page with `offset` and `limit`, filter through query parameters,
|
|
15
|
+
and honour `fields` for attribute selection. A hand-rolled paging scheme on a
|
|
16
|
+
TMF resource is a contract break.
|
|
17
|
+
- Errors use the TMF error body — `code`, `reason`, and `message` where present
|
|
18
|
+
— rather than a local error shape.
|
|
19
|
+
- Notifications follow the hub/listener pattern the API defines rather than a
|
|
20
|
+
bespoke webhook.
|
|
21
|
+
- A breaking change to a published interface needs a version, not an edit. Say
|
|
22
|
+
so explicitly when you see one.
|
|
23
|
+
|
|
24
|
+
State the specific TMF API and version when a finding depends on it, so the
|
|
25
|
+
author can check the same document you did.
|
package/standards/manifest.json
CHANGED
|
@@ -28,6 +28,60 @@
|
|
|
28
28
|
"apps/**/src/**/*.ts"
|
|
29
29
|
]
|
|
30
30
|
},
|
|
31
|
+
"angular": {
|
|
32
|
+
"title": "Angular",
|
|
33
|
+
"source": "standards/stacks/angular.md",
|
|
34
|
+
"globs": [
|
|
35
|
+
"**/*.component.ts",
|
|
36
|
+
"**/*.component.html",
|
|
37
|
+
"**/*.service.ts",
|
|
38
|
+
"**/*.directive.ts",
|
|
39
|
+
"**/*.pipe.ts",
|
|
40
|
+
"**/*.guard.ts",
|
|
41
|
+
"**/*.interceptor.ts",
|
|
42
|
+
"src/app/**",
|
|
43
|
+
"apps/**/src/app/**",
|
|
44
|
+
"packages/**/src/app/**"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"vue": {
|
|
48
|
+
"title": "Vue",
|
|
49
|
+
"source": "standards/stacks/vue.md",
|
|
50
|
+
"globs": [
|
|
51
|
+
"**/*.vue",
|
|
52
|
+
"composables/**",
|
|
53
|
+
"pages/**",
|
|
54
|
+
"server/**/*.ts",
|
|
55
|
+
"src/**/*.ts",
|
|
56
|
+
"app/**/*.ts",
|
|
57
|
+
"apps/**/src/**/*.ts",
|
|
58
|
+
"packages/**/src/**/*.ts"
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"svelte": {
|
|
62
|
+
"title": "Svelte / SvelteKit",
|
|
63
|
+
"source": "standards/stacks/svelte.md",
|
|
64
|
+
"globs": [
|
|
65
|
+
"**/*.svelte",
|
|
66
|
+
"**/*.svelte.ts",
|
|
67
|
+
"**/*.svelte.js",
|
|
68
|
+
"src/routes/**",
|
|
69
|
+
"src/lib/**",
|
|
70
|
+
"apps/**/src/routes/**",
|
|
71
|
+
"apps/**/src/lib/**",
|
|
72
|
+
"packages/**/src/lib/**"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"dom": {
|
|
76
|
+
"title": "Browser / DOM",
|
|
77
|
+
"source": "standards/stacks/dom.md",
|
|
78
|
+
"globs": [
|
|
79
|
+
"**/*.js",
|
|
80
|
+
"**/*.mjs",
|
|
81
|
+
"**/*.ts",
|
|
82
|
+
"**/*.html"
|
|
83
|
+
]
|
|
84
|
+
},
|
|
31
85
|
"react-native": {
|
|
32
86
|
"title": "React Native",
|
|
33
87
|
"source": "standards/stacks/react-native.md",
|
|
@@ -126,10 +180,26 @@
|
|
|
126
180
|
"tooling": [
|
|
127
181
|
"javascript"
|
|
128
182
|
],
|
|
129
|
-
"web": [
|
|
183
|
+
"web-react": [
|
|
130
184
|
"javascript",
|
|
131
185
|
"react"
|
|
132
186
|
],
|
|
187
|
+
"web-angular": [
|
|
188
|
+
"javascript",
|
|
189
|
+
"angular"
|
|
190
|
+
],
|
|
191
|
+
"web-vue": [
|
|
192
|
+
"javascript",
|
|
193
|
+
"vue"
|
|
194
|
+
],
|
|
195
|
+
"web-svelte": [
|
|
196
|
+
"javascript",
|
|
197
|
+
"svelte"
|
|
198
|
+
],
|
|
199
|
+
"web-vanilla": [
|
|
200
|
+
"javascript",
|
|
201
|
+
"dom"
|
|
202
|
+
],
|
|
133
203
|
"mobile-rn": [
|
|
134
204
|
"javascript",
|
|
135
205
|
"react",
|
|
@@ -174,6 +244,7 @@
|
|
|
174
244
|
},
|
|
175
245
|
"$aliasComment": "Legacy profile names accepted by sync-targets.txt for backwards compatibility.",
|
|
176
246
|
"profileAliases": {
|
|
247
|
+
"web": "web-react",
|
|
177
248
|
"mobile": "mobile-rn",
|
|
178
249
|
"java": "service-java",
|
|
179
250
|
"go": "service-go",
|
|
@@ -190,18 +261,18 @@
|
|
|
190
261
|
"title": "AGENTS.md (OpenAI Codex, Copilot coding agent, Jules, Cursor agent, Devin)",
|
|
191
262
|
"enabled": true
|
|
192
263
|
},
|
|
264
|
+
"codex": {
|
|
265
|
+
"$comment": "Renders AGENTS.md, the same file the \"agents\" vendor writes — that is the file OpenAI Codex reads, and inventing a second Codex-specific path would render rules nothing looks for. Listed separately because an operator looking for Codex should find it by name; selecting either produces the same artifact, and selecting both writes it once.",
|
|
266
|
+
"title": "OpenAI Codex (AGENTS.md)",
|
|
267
|
+
"enabled": true
|
|
268
|
+
},
|
|
193
269
|
"claude": {
|
|
194
270
|
"title": "Claude Code / Claude in GitHub",
|
|
195
271
|
"enabled": true
|
|
196
272
|
},
|
|
197
273
|
"cursor": {
|
|
198
274
|
"title": "Cursor rules",
|
|
199
|
-
"enabled":
|
|
200
|
-
},
|
|
201
|
-
"skills": {
|
|
202
|
-
"$comment": "Ships off. It renders the same rules as the claude vendor in a different shape, and a repository selecting both loads every stack twice. Select it INSTEAD of claude where per-stack loading is wanted: redline init --vendors skills,copilot,agents.",
|
|
203
|
-
"title": "Claude skills (per-stack, loaded on demand)",
|
|
204
|
-
"enabled": false
|
|
275
|
+
"enabled": true
|
|
205
276
|
}
|
|
206
277
|
},
|
|
207
278
|
"$deterministicComment": "Rules a checker decides without a model. The classification lives HERE and not in the markdown deliberately: standards/*.md is what a reviewer reads, and removing a rule from it because a checker also covers it would narrow what the model considers. Every rule is classified by construction \u2014 listed here means deterministic, absent means judgement. Ids are permanent and are never rewritten by this list. scripts/validate.mjs fails the build if an id here has no implementation in cli/policy/checks.ts, which is the failure mode where a rule is silently enforced by nobody.",
|