@wrongstack/techstack 0.307.1 → 0.308.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,186 @@
1
+ /**
2
+ * TechStack — Rulebook Resolver (implementation).
3
+ *
4
+ * Pure functions that combine registry evidence (the facts) with a project
5
+ * rulebook (the policy) into a single `ResolvedTarget` per dependency. The
6
+ * resolver is called by the engine after Inventory + Enrichment but before
7
+ * the Analyzer. It does NOT call into the LLM — it only manipulates typed
8
+ * data. The LLM (advisor) reads the resolver's output as input.
9
+ *
10
+ * Determinism: same inputs + same `now` → same output. No I/O and no clock
11
+ * reads (the caller passes `now`), so tests pin the full decision matrix.
12
+ *
13
+ * Decision order (per SDD §1 "deterministic engines for facts"):
14
+ * 1. `latestStable` missing → `registry_unavailable` (never fabricate)
15
+ * 2. `banned` match → `banned_violated` / `banned_unresolved_replacement`
16
+ * 3. `pinned` match → `pinned_within_max` / `pinned_above_max`
17
+ * 4. `deferred` match → `deferred_until_future` / `deferred_overdue`
18
+ * 5. `preferred` match → `preferred_no_status_change`
19
+ * 6. nothing matched → `unconstrained`
20
+ *
21
+ * @see packages/techstack/src/policy/rulebook.ts — the input shape.
22
+ * @see packages/techstack/src/policy/status.ts — `compareVersions` is the ONE
23
+ * semver comparison primitive reused by `satisfiesRange`.
24
+ */
25
+ import type { DependencyObservation } from '../types.js';
26
+ import type { BanEntry, DeferEntry, PackageSelector, PinEntry, PreferEntry, Rulebook, VersionRange } from './rulebook.js';
27
+ /**
28
+ * Why the resolver decided what it decided. One per affected dependency.
29
+ * The `reason` is a stable identifier — used by tests and by the UI as a
30
+ * translation key. The `detail` is a human-readable string.
31
+ */
32
+ export type ResolveReason = 'registry_unavailable' | 'pinned_within_max' | 'pinned_above_max' | 'banned_violated' | 'banned_unresolved_replacement' | 'deferred_until_future' | 'deferred_overdue' | 'preferred_no_status_change' | 'unconstrained';
33
+ /**
34
+ * Provenance for the rule that drove a decision. Kept separate from the
35
+ * engine-wide `Evidence` union on purpose: a rulebook hit is policy, not
36
+ * registry/manifest/audit evidence, and must never be presented as such.
37
+ */
38
+ export interface RuleEvidence {
39
+ readonly rule: 'pinned' | 'banned' | 'deferred' | 'preferred';
40
+ readonly detail: string;
41
+ }
42
+ /**
43
+ * The single resolved target for one dependency. The resolver produces one
44
+ * of these per `(dependency, rulebook)` pair. The engine then merges this
45
+ * into the analyzer's finding stream.
46
+ */
47
+ export interface ResolvedTarget {
48
+ readonly dependencyId: string;
49
+ readonly selectorHits: {
50
+ readonly pinned: readonly PinEntry[];
51
+ readonly banned: readonly BanEntry[];
52
+ readonly deferred: readonly DeferEntry[];
53
+ readonly preferred: readonly PreferEntry[];
54
+ };
55
+ /** The version the rulebook wants, or `undefined` if no rule constrains it. */
56
+ readonly targetVersion: VersionRange | undefined;
57
+ /** Whether the evaluated version satisfies the rulebook. */
58
+ readonly satisfies: boolean;
59
+ /** Reason for the decision — stable identifier + human detail. */
60
+ readonly reason: ResolveReason;
61
+ readonly detail: string;
62
+ /** Which rule fired and why. Empty for `unconstrained`/`registry_unavailable`. */
63
+ readonly ruleEvidence: readonly RuleEvidence[];
64
+ }
65
+ /**
66
+ * Validate a `PackageSelector` against a `(ecosystem, name)` pair.
67
+ *
68
+ * Wildcard semantics:
69
+ * - `namePattern` uses `*` (any non-`/` sequence) and `**` (any sequence).
70
+ * - `selector.ecosystem` is exact match (case-insensitive, trimmed).
71
+ * - `selector.name` is exact match (case-insensitive, trimmed).
72
+ * - At least one of `name`, `namePattern`, `ecosystem` must be present.
73
+ * - An empty selector matches nothing.
74
+ * - When both `name` and `namePattern` are present, either may match.
75
+ * - An `ecosystem`-only selector matches every package in that ecosystem.
76
+ */
77
+ export declare function matchesSelector(selector: PackageSelector, ecosystem: string, name: string): boolean;
78
+ /**
79
+ * Find all rulebook entries that match a `(ecosystem, name)` pair.
80
+ * Pure function. Used by the advisor to filter the rulebook by context.
81
+ */
82
+ export declare function findRuleMatches(rulebook: Rulebook, ecosystem: string, name: string): {
83
+ readonly pinned: readonly PinEntry[];
84
+ readonly banned: readonly BanEntry[];
85
+ readonly deferred: readonly DeferEntry[];
86
+ readonly preferred: readonly PreferEntry[];
87
+ };
88
+ /**
89
+ * The structured form of a rulebook `VersionRange`. The grammar is the
90
+ * semver-range subset the engine already understands — it is intentionally
91
+ * NOT a generic semver-range parser.
92
+ */
93
+ export type ParsedRange = {
94
+ readonly kind: 'exact';
95
+ readonly version: string;
96
+ } | {
97
+ readonly kind: 'caret';
98
+ readonly version: string;
99
+ } | {
100
+ readonly kind: 'tilde';
101
+ readonly version: string;
102
+ } | {
103
+ readonly kind: 'comparator';
104
+ readonly op: '>=' | '<=' | '>' | '<' | '=';
105
+ readonly version: string;
106
+ } | {
107
+ readonly kind: 'and';
108
+ readonly ranges: readonly ParsedRange[];
109
+ } | {
110
+ readonly kind: 'or';
111
+ readonly ranges: readonly ParsedRange[];
112
+ };
113
+ export declare class RangeParseError extends Error {
114
+ readonly input: string;
115
+ readonly position: number;
116
+ constructor(input: string, position: number, message: string);
117
+ }
118
+ /**
119
+ * Parse a `VersionRange` string into a structured range.
120
+ *
121
+ * Grammar:
122
+ * - exact: `1.2.3`
123
+ * - caret: `^1.2.3`
124
+ * - tilde: `~1.2.3`
125
+ * - comparator: `>=1.2.3` (also `<=`, `>`, `<`, `=`)
126
+ * - AND: `>=1.2.3 <2.0.0` (whitespace-separated)
127
+ * - OR: `^1.2.3 || ^2.0.0`
128
+ *
129
+ * Invalid inputs throw `RangeParseError`.
130
+ */
131
+ export declare function parseRange(input: string): ParsedRange;
132
+ /**
133
+ * Check whether `version` satisfies `range`. Pure function.
134
+ * Re-uses `compareVersions` from `policy/status.ts` as the single semver
135
+ * comparison primitive — including its prerelease tie-break semantics.
136
+ */
137
+ export declare function satisfiesRange(version: string, range: ParsedRange): boolean;
138
+ /**
139
+ * Resolve a single dependency against the rulebook. Pure: same inputs +
140
+ * same `now` → same output. See the decision order in the module header.
141
+ *
142
+ * @param dep Enriched dependency. A missing `latestStable` short-circuits
143
+ * to `registry_unavailable` — the resolver never fabricates.
144
+ * @param rulebook Optional rulebook. `undefined` → `unconstrained`.
145
+ * @param now Reference time for `deferred.until`; the caller passes it
146
+ * to keep this function deterministic.
147
+ */
148
+ export declare function resolveDependency(dep: DependencyObservation, rulebook: Rulebook | undefined, now: Date): ResolvedTarget;
149
+ /**
150
+ * Resolve an entire observation list. Non-registry sources (path, git,
151
+ * system) are always `unconstrained` — the rulebook only addresses registry
152
+ * packages.
153
+ */
154
+ export declare function resolveDependencies(dependencies: readonly DependencyObservation[], rulebook: Rulebook | undefined, now: Date): readonly ResolvedTarget[];
155
+ /**
156
+ * A "summary" of a rulebook across the project. The advisor (LLM) reads
157
+ * this to know what to recommend; the analyzer adds these as evidence
158
+ * to its findings. The resolver is the only writer.
159
+ */
160
+ export interface RulebookSummary {
161
+ readonly totalPinned: number;
162
+ readonly totalBanned: number;
163
+ readonly totalDeferred: number;
164
+ readonly totalPreferred: number;
165
+ readonly packageManagerPreference: {
166
+ readonly ecosystem: string;
167
+ readonly manager: string;
168
+ }[];
169
+ readonly violations: readonly {
170
+ readonly dependencyId: string;
171
+ readonly reason: ResolveReason;
172
+ readonly detail: string;
173
+ }[];
174
+ /** Human-readable snapshot of the rulebook, ≤ 4 KiB. */
175
+ readonly digest: string;
176
+ }
177
+ /**
178
+ * Build a `RulebookSummary` from a list of resolved targets + the rulebook.
179
+ * Pure function. The digest is the only string the LLM is allowed to read
180
+ * verbatim; it MUST NOT contain `latestStable`/version numbers from the
181
+ * registry (those would be a fabrication risk if the rulebook is stale).
182
+ *
183
+ * Contract only — not implemented yet; nothing depends on it today.
184
+ */
185
+ export type SummarizeRulebook = (rulebook: Rulebook, resolved: readonly ResolvedTarget[]) => RulebookSummary;
186
+ //# sourceMappingURL=resolver.d.ts.map
@@ -0,0 +1,213 @@
1
+ /**
2
+ * TechStack — Project Rulebook.
3
+ *
4
+ * A Rulebook is a deterministic, project-local policy document that overrides
5
+ * or supplements the registry-driven defaults produced by the TechStack engine.
6
+ * It exists so that the LLM (`advisor/`) never has to invent policies like
7
+ * "we pin React 18 because of the EOL fork" — that intent lives in this file,
8
+ * authored by humans, and consumed by the Resolver as ground truth.
9
+ *
10
+ * Design principles (mirrors SDD §1: "deterministic engines for facts, agents for interpretation"):
11
+ * - The rulebook is **data**, not code. It is a JSON file under the target project.
12
+ * - The rulebook is **read-only** to the engine. The engine proposes changes; humans apply them.
13
+ * - The rulebook never sets `latestStable` — that field is always registry evidence.
14
+ * - The rulebook only constrains: pin, ban, prefer, defer, and provides an
15
+ * `advisorHints` bag for the LLM to read (not write).
16
+ *
17
+ * File location (resolution order, first hit wins):
18
+ * 1. Path passed to `loadRulebook(targetRoot, overridePath)`
19
+ * 2. `<targetRoot>/.wrongstack/techstack.rulebook.json`
20
+ * 3. `<targetRoot>/.wrongstack/techstack.rulebook.yaml`
21
+ * 4. No rulebook → Resolver falls back to "no overrides" mode.
22
+ *
23
+ * @see docs/specs/techstack-sdd.md §1, §7
24
+ * @see packages/techstack/src/policy/resolver.ts — the consumer of this contract.
25
+ */
26
+ /** A semver-range string. Resolved by `policy/resolver.ts#parseRange`. */
27
+ export type VersionRange = string & {
28
+ readonly __brand: 'VersionRange';
29
+ };
30
+ /** ISO 8601 timestamp. Used for `defer.until`, `banned.since`, etc. */
31
+ export type IsoDateTime = string & {
32
+ readonly __brand: 'IsoDateTime';
33
+ };
34
+ /** Wildcard matcher over `(ecosystem, name)` pairs. */
35
+ export interface PackageSelector {
36
+ readonly ecosystem?: string | undefined;
37
+ readonly name?: string | undefined;
38
+ /**
39
+ * Wildcard glob over the package name. `*` matches any sequence of characters
40
+ * EXCEPT `/`; `**` matches across path separators. The resolver pins this
41
+ * down to a concrete matching function.
42
+ */
43
+ readonly namePattern?: string | undefined;
44
+ }
45
+ /**
46
+ * Pin a package to a maximum version (or version range). Updates beyond
47
+ * `max` are flagged as `blocked_by_constraints` (not `update_available_*`).
48
+ * The reason is surfaced in advisor output and analyzer rationale.
49
+ */
50
+ export interface PinEntry {
51
+ readonly selector: PackageSelector;
52
+ readonly max: VersionRange;
53
+ readonly reason: string;
54
+ /** Review-after date. After this date the rulebook emits a `investigate` finding. */
55
+ readonly reviewAfter?: IsoDateTime | undefined;
56
+ }
57
+ /**
58
+ * Ban a package entirely. The resolver emits a `replace` finding using
59
+ * `replacement` as the migration target when present.
60
+ */
61
+ export interface BanEntry {
62
+ readonly selector: PackageSelector;
63
+ readonly reason: string;
64
+ readonly replacement?: ReplacementEntry | undefined;
65
+ /** When the ban took effect; advisory findings are not emitted for usages before this date. */
66
+ readonly since?: IsoDateTime | undefined;
67
+ }
68
+ /**
69
+ * Replacement target for a banned package. The Resolver emits a `replace`
70
+ * finding with `action: 'replace'` and `breakingRisk` derived from the
71
+ * major-version gap between current and replacement.
72
+ */
73
+ export interface ReplacementEntry {
74
+ readonly ecosystem: string;
75
+ readonly name: string;
76
+ readonly minVersion?: VersionRange | undefined;
77
+ readonly notes?: string | undefined;
78
+ }
79
+ /**
80
+ * Defer an upgrade — the package is intentionally out-of-date and the
81
+ * upgrade is scheduled. The Resolver tags affected dependencies as
82
+ * `deferred_intentional` (a new finding kind) and suppresses
83
+ * `update_available_*` findings until `until`.
84
+ */
85
+ export interface DeferEntry {
86
+ readonly selector: PackageSelector;
87
+ readonly until: IsoDateTime;
88
+ readonly reason: string;
89
+ /** Reference key (e.g. "JIRA-1234") for traceability. */
90
+ readonly ticket?: string | undefined;
91
+ }
92
+ /**
93
+ * Preferred package — when multiple implementations are valid, the advisor
94
+ * prefers this one. The Resolver does not change `current` status; only the
95
+ * advisor's ranking function reads this.
96
+ */
97
+ export interface PreferEntry {
98
+ readonly selector: PackageSelector;
99
+ readonly context: string;
100
+ /** Optional replacement for a sibling package — e.g. "prefer `vitest` over `jest`". */
101
+ readonly deprecates?: PackageSelector | undefined;
102
+ }
103
+ /**
104
+ * Top-level package manager preference. The Resolver cross-references this
105
+ * with `Workspace.packageManager` and emits a finding when a workspace
106
+ * disagrees with the rulebook.
107
+ */
108
+ export interface PackageManagerPreference {
109
+ readonly npm?: string | undefined;
110
+ readonly python?: string | undefined;
111
+ readonly rust?: string | undefined;
112
+ readonly go?: string | undefined;
113
+ readonly dotnet?: string | undefined;
114
+ readonly php?: string | undefined;
115
+ readonly dart?: string | undefined;
116
+ readonly java?: string | undefined;
117
+ readonly ruby?: string | undefined;
118
+ readonly swift?: string | undefined;
119
+ readonly elixir?: string | undefined;
120
+ }
121
+ /** Schema version of the rulebook on disk. Bump on breaking changes. */
122
+ export type RulebookSchemaVersion = '1';
123
+ export interface Rulebook {
124
+ readonly schemaVersion: RulebookSchemaVersion;
125
+ readonly projectId?: string | undefined;
126
+ readonly pinned: readonly PinEntry[];
127
+ readonly banned: readonly BanEntry[];
128
+ readonly deferred: readonly DeferEntry[];
129
+ readonly preferred: readonly PreferEntry[];
130
+ readonly packageManager?: PackageManagerPreference | undefined;
131
+ /**
132
+ * Free-form context for the LLM advisor. The Resolver never reads this;
133
+ * it is exposed verbatim to the advisor at suggestion time.
134
+ * Constraints: max 8 KiB total, no execution on read.
135
+ */
136
+ readonly advisorHints?: string | undefined;
137
+ }
138
+ /** Result of a rulebook load attempt. Never throws for "missing" — only for "malformed". */
139
+ export type LoadRulebookResult = {
140
+ readonly kind: 'absent';
141
+ } | {
142
+ readonly kind: 'loaded';
143
+ readonly rulebook: Rulebook;
144
+ readonly source: string;
145
+ } | {
146
+ readonly kind: 'malformed';
147
+ readonly source: string;
148
+ readonly errors: readonly string[];
149
+ };
150
+ /**
151
+ * In-memory file system abstract for the rulebook loader. Tests inject a
152
+ * deterministic map; production uses `node:fs/promises`.
153
+ */
154
+ export interface RulebookFileSystem {
155
+ exists(path: string): Promise<boolean>;
156
+ readFile(path: string): Promise<string>;
157
+ stat(path: string): Promise<{
158
+ readonly size: number;
159
+ }>;
160
+ }
161
+ /**
162
+ * Locate and parse a rulebook from the target project tree.
163
+ *
164
+ * Resolution order:
165
+ * 1. `overridePath` (if provided and exists)
166
+ * 2. `<targetRoot>/.wrongstack/techstack.rulebook.json`
167
+ * 3. `<targetRoot>/.wrongstack/techstack.rulebook.yaml`
168
+ * 4. `absent`
169
+ *
170
+ * JSON is fully supported. YAML files are detected but reported as
171
+ * `malformed` with an explanatory error — the vendored minimal YAML parser
172
+ * is scheduled for P3.1 (`adapters/parse-utils.ts`), not yet available.
173
+ *
174
+ * @param targetRoot Absolute path to the analyzed project root.
175
+ * @param overridePath Optional explicit path to a rulebook file.
176
+ * @param io File system abstraction; default uses real fs. Tests inject a mock.
177
+ */
178
+ export declare function loadRulebook(targetRoot: string, overridePath?: string, io?: RulebookFileSystem): Promise<LoadRulebookResult>;
179
+ /**
180
+ * Validate a parsed rulebook object against `Rulebook`. Returns the list of
181
+ * human-readable errors. Empty list means valid.
182
+ *
183
+ * Schema invariants (any violation is an error):
184
+ * - `schemaVersion === '1'`
185
+ * - `pinned[].max` is a non-empty version range
186
+ * - `pinned[].reason` is non-empty
187
+ * - `banned[].reason` is non-empty
188
+ * - `deferred[].until` is a valid ISO 8601 timestamp and in the future
189
+ * - `advisorHints` ≤ 8192 bytes when serialized
190
+ * - Each `selector` reaches at least one of `name`, `namePattern`, `ecosystem`
191
+ * - At least one of `pinned`, `banned`, `deferred`, `preferred`,
192
+ * `packageManager`, `advisorHints` must be present (a totally empty
193
+ * rulebook is treated as "no rulebook" — see `LoadRulebookResult.kind`).
194
+ */
195
+ export declare function validateRulebook(input: unknown): readonly string[];
196
+ /**
197
+ * Stable JSON Schema for the rulebook. Authored as a string literal so the
198
+ * schema travels with the package and can be emitted by tooling.
199
+ * Implementation: emitted by `scripts/check-rulebook.mjs` at CI time.
200
+ */
201
+ export declare const RULEBOOK_JSON_SCHEMA: string;
202
+ /**
203
+ * Match a `(ecosystem, name)` pair against a PackageSelector.
204
+ * Pure function. Symmetric: `matchesSelector(selector, ecosystem, name)`.
205
+ */
206
+ export type MatchesSelector = (selector: PackageSelector, ecosystem: string, name: string) => boolean;
207
+ /**
208
+ * Brand-construction helper for the runtime. Tests that build fixtures
209
+ * call this so branded types stay opaque.
210
+ */
211
+ export declare const asVersionRange: (s: string) => VersionRange;
212
+ export declare const asIsoDateTime: (s: string) => IsoDateTime;
213
+ //# sourceMappingURL=rulebook.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/techstack",
3
- "version": "0.307.1",
3
+ "version": "0.308.1",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack TechStack — cross-language dependency intelligence for the active target project: discover, inventory, enrich, analyze, and report.",
6
6
  "repository": {
@@ -26,8 +26,8 @@
26
26
  "!dist/**/*.map"
27
27
  ],
28
28
  "dependencies": {
29
- "@wrongstack/core": "0.307.1",
30
- "@wrongstack/tools": "0.307.1"
29
+ "@wrongstack/core": "0.308.1",
30
+ "@wrongstack/tools": "0.308.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^26.2.0",