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.
Files changed (86) hide show
  1. package/README.md +23 -7
  2. package/dist/bin/redline.js +338 -43
  3. package/dist/bin/redline.js.map +1 -1
  4. package/dist/commands/init.js +353 -34
  5. package/dist/commands/init.js.map +1 -1
  6. package/dist/commands/remove.js +43 -1
  7. package/dist/commands/remove.js.map +1 -1
  8. package/dist/commands/status.js +94 -0
  9. package/dist/commands/status.js.map +1 -0
  10. package/dist/commands/verify.js +82 -2
  11. package/dist/commands/verify.js.map +1 -1
  12. package/dist/config/redline-json.js +63 -2
  13. package/dist/config/redline-json.js.map +1 -1
  14. package/dist/core/git.js +50 -3
  15. package/dist/core/git.js.map +1 -1
  16. package/dist/core/version.js +6 -0
  17. package/dist/core/version.js.map +1 -1
  18. package/dist/detect/existing.js +151 -0
  19. package/dist/detect/existing.js.map +1 -0
  20. package/dist/detect/setup.js +200 -0
  21. package/dist/detect/setup.js.map +1 -0
  22. package/dist/detect/stack.js +56 -17
  23. package/dist/detect/stack.js.map +1 -1
  24. package/dist/exempt/parse.js +8 -1
  25. package/dist/exempt/parse.js.map +1 -1
  26. package/dist/metrics/options.js +0 -9
  27. package/dist/metrics/options.js.map +1 -1
  28. package/dist/platforms/azure/install.js +9 -9
  29. package/dist/platforms/azure/install.js.map +1 -1
  30. package/dist/platforms/azure/verify.js +10 -2
  31. package/dist/platforms/azure/verify.js.map +1 -1
  32. package/dist/platforms/github/install.js +192 -26
  33. package/dist/platforms/github/install.js.map +1 -1
  34. package/dist/platforms/github/preflight.js +63 -0
  35. package/dist/platforms/github/preflight.js.map +1 -0
  36. package/dist/platforms/github/vendor.js +81 -0
  37. package/dist/platforms/github/vendor.js.map +1 -0
  38. package/dist/platforms/github/verify.js +79 -7
  39. package/dist/platforms/github/verify.js.map +1 -1
  40. package/dist/platforms/types.js +8 -0
  41. package/dist/platforms/types.js.map +1 -1
  42. package/dist/policy/diff.js +26 -9
  43. package/dist/policy/diff.js.map +1 -1
  44. package/dist/render/contexts.js +39 -0
  45. package/dist/render/contexts.js.map +1 -0
  46. package/dist/render/profile.js +44 -10
  47. package/dist/render/profile.js.map +1 -1
  48. package/dist/render/standards.js +10 -1
  49. package/dist/render/standards.js.map +1 -1
  50. package/dist/render/vendors.js +14 -53
  51. package/dist/render/vendors.js.map +1 -1
  52. package/dist/rules/catalogue.js +127 -0
  53. package/dist/rules/catalogue.js.map +1 -0
  54. package/dist/ui/facts.js +111 -0
  55. package/dist/ui/facts.js.map +1 -0
  56. package/dist/ui/prompt.js +330 -0
  57. package/dist/ui/prompt.js.map +1 -0
  58. package/dist/ui/report.js +214 -0
  59. package/dist/ui/report.js.map +1 -0
  60. package/dist/ui/tty.js +576 -0
  61. package/dist/ui/tty.js.map +1 -0
  62. package/dist/ui/wizard.js +293 -0
  63. package/dist/ui/wizard.js.map +1 -0
  64. package/dist/verify/remote.js +33 -0
  65. package/dist/verify/remote.js.map +1 -1
  66. package/package.json +13 -1
  67. package/platforms/azure/gate-template-github.yml +152 -0
  68. package/platforms/azure/gate-template.yml +59 -6
  69. package/scripts/check-pins.mjs +84 -1
  70. package/scripts/fetch-stars.mjs +92 -0
  71. package/scripts/lib/rules.d.mts +18 -0
  72. package/scripts/publish-local.mjs +183 -0
  73. package/standards/contexts/speckit.md +22 -0
  74. package/standards/contexts/tmf.md +25 -0
  75. package/standards/manifest.json +78 -7
  76. package/standards/stacks/angular.md +57 -0
  77. package/standards/stacks/dom.md +61 -0
  78. package/standards/stacks/svelte.md +45 -0
  79. package/standards/stacks/vue.md +54 -0
  80. package/templates/redline.yml +9 -9
  81. package/workflows/dashboard.yml +1 -1
  82. package/workflows/redline-collect.yml +1 -1
  83. package/workflows/redline-gate.yml +38 -7
  84. package/workflows/seed-canary.yml +2 -2
  85. package/workflows/weekly-digest.yml +1 -1
  86. 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
- # NOTE for whoever splits this job up: on GitHub the security jobs
76
- # (dependency-review, secrets) are excluded from the exemption. Here the
77
- # gate is one `redline verify --gate` run that checks configuration
78
- # drift only no scanning step exists to downgrade. Adding one means
79
- # excluding it from this branch, exactly as the GitHub workflow does.
80
- if [ "$state" = "failed" ] && [ -n "${SOFT_FAIL_LABELS:-}" ]; then
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)
@@ -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
- console.log(`\n${pins.length} pin(s), ${errors} error(s), ${updates} update(s) available`);
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.
@@ -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": false
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.",