baldart 4.64.0 → 4.65.0

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.
@@ -479,6 +479,54 @@ async function detectState(cwd, opts = {}) {
479
479
  }
480
480
  } catch (_) { /* never block doctor on i18n probe */ }
481
481
 
482
+ // ---- Design-system manifest + DTCG tokens (since v4.65.0) ----------
483
+ // Two backfills, both gated on has_design_system (no separate flag — the
484
+ // manifest rides on the design-system layer). (1) "default-on + nudge":
485
+ // when the registry exists but specs are missing or still prose-only (no
486
+ // machine-readable HEAD), nudge /design-system-init. (2) token SSOT: when
487
+ // paths.design_tokens is set, flag generated outputs that drifted from the
488
+ // DTCG source (or a hand-edited generated file).
489
+ state.dsManifestNudge = false;
490
+ state.dsManifestReason = '';
491
+ state.tokensStale = false;
492
+ state.tokensStalePaths = [];
493
+ try {
494
+ if (config && !config.__malformed && config.features && config.features.has_design_system === true) {
495
+ const dsRoot = (config.paths && config.paths.design_system) || '';
496
+ if (dsRoot) {
497
+ const compDir = path.join(cwd, dsRoot, 'components');
498
+ let specs = [];
499
+ try { specs = fs.readdirSync(compDir).filter((f) => f.endsWith('.md')); } catch (_) { specs = []; }
500
+ if (specs.length === 0) {
501
+ state.dsManifestNudge = true;
502
+ state.dsManifestReason = 'no per-component specs found';
503
+ } else {
504
+ // Cheap sample: does ANY spec carry a frontmatter HEAD (starts with `---`)?
505
+ const structured = specs.slice(0, 8).some((f) => {
506
+ try { return fs.readFileSync(path.join(compDir, f), 'utf8').startsWith('---'); }
507
+ catch (_) { return false; }
508
+ });
509
+ if (!structured) {
510
+ state.dsManifestNudge = true;
511
+ state.dsManifestReason = 'specs are prose-only (no machine-readable HEAD)';
512
+ }
513
+ }
514
+ }
515
+ // DTCG token SSOT staleness.
516
+ const src = (config.paths && config.paths.design_tokens) || '';
517
+ const outs = (config.design_tokens && config.design_tokens.outputs) || [];
518
+ if (src && outs.length) {
519
+ const TokensGenerator = require('../utils/tokens-generator');
520
+ const st = new TokensGenerator(cwd).isStale({ source: src, outputs: outs });
521
+ if (st.stale) {
522
+ state.tokensStale = true;
523
+ state.tokensStalePaths = (st.reasons || []).map((r) => r.path);
524
+ state.tokensStaleError = st.error || null;
525
+ }
526
+ }
527
+ }
528
+ } catch (_) { /* never block doctor on design-system probe */ }
529
+
482
530
  // ---- Auto-deploy allowlist presence (since v4.59.0) ----------------
483
531
  // Purely informational: git.auto_deploy bounds what `/new -auto-ship`
484
532
  // may execute as an OUTWARD action. An empty allowlist is the SAFE
@@ -1013,6 +1061,43 @@ function planActions(state) {
1013
1061
  });
1014
1062
  }
1015
1063
 
1064
+ // ---- Design-system manifest + DTCG tokens (since v4.65.0) ----------
1065
+ // (1) Rebuild stale generated token artifacts from the DTCG SSOT — offline,
1066
+ // deterministic, idempotent → autoOk. (2) Nudge the manifest bootstrap when
1067
+ // the registry is present but specs are missing/prose-only (the "default-on
1068
+ // + nudge" behavior; informational, never auto-mutates source).
1069
+ if (state.tokensStale) {
1070
+ actions.push({
1071
+ key: 'tokens-build',
1072
+ label: state.tokensStaleError
1073
+ ? 'Design tokens: DTCG source has an error — fix then rebuild'
1074
+ : `Rebuild generated token artifacts from the DTCG source (${state.tokensStalePaths.join(', ')})`,
1075
+ why: state.tokensStaleError
1076
+ ? `paths.design_tokens is set but the DTCG source could not be parsed: ${state.tokensStaleError}. Fix the .tokens.json, then \`baldart tokens build\`.`
1077
+ : 'The generated token output(s) drifted from the DTCG SSOT (a hand-edit to a generated file, or the source changed without a rebuild). `baldart tokens build` regenerates them (offline, no API cost). Edit `.tokens.json`, never the generated output.',
1078
+ autoOk: !state.tokensStaleError, // a parse error needs a human; a plain rebuild is safe
1079
+ run: async () => {
1080
+ if (state.tokensStaleError) {
1081
+ UI.warning(`DTCG source error: ${state.tokensStaleError}. Fix it, then run \`baldart tokens build\`.`);
1082
+ return;
1083
+ }
1084
+ const tokens = require('./tokens');
1085
+ await tokens.build({ cwd: state.cwd });
1086
+ },
1087
+ });
1088
+ }
1089
+ if (state.dsManifestNudge) {
1090
+ actions.push({
1091
+ key: 'ds-manifest-nudge',
1092
+ label: `Design-system specs need a machine-readable upgrade — run /design-system-init (${state.dsManifestReason})`,
1093
+ why: `features.has_design_system is true but ${state.dsManifestReason}. Agents fall back to reading the monolithic INDEX + component source (slow). Running /design-system-init (upgrade mode) generates the per-component frontmatter HEAD + a thin INDEX router so discovery reads small structured specs on demand. See framework/agents/component-manifest-schema.md.`,
1094
+ autoOk: false, // a skill that reads source + enriches — never run unattended from doctor
1095
+ run: async () => {
1096
+ UI.info('Run `/design-system-init` in your editor to upgrade the per-component specs to the machine-readable HEAD and regenerate the thin INDEX router. See framework/docs/COMPONENT-MANIFEST-LAYER.md.');
1097
+ },
1098
+ });
1099
+ }
1100
+
1016
1101
  // Auto-deploy allowlist (since v4.59.0). Advisory print-only — surfaced ONLY
1017
1102
  // when the allowlist is non-empty (the safe default of empty/unset opens
1018
1103
  // nothing, so it warrants no nag). Reminds the user that `/new -auto-ship`
@@ -0,0 +1,80 @@
1
+ /**
2
+ * `baldart tokens build` — regenerate the stack-native token artifacts from the
3
+ * DTCG SSOT (since v4.65.0).
4
+ *
5
+ * The design-token source of truth is the DTCG `.tokens.json` at
6
+ * `paths.design_tokens`; the artifacts agents/components consume (tokens.ts, a
7
+ * CSS custom-property sheet) are GENERATED from it and carry a
8
+ * `baldart-generated` banner. This verb is what the `/design-system-init` skill,
9
+ * `baldart doctor`, and (later) a git hook CALL — none of them reimplement the
10
+ * generation (the SSOT for that is `src/utils/tokens-generator.js`).
11
+ *
12
+ * Config consumed (baldart.config.yml):
13
+ * paths.design_tokens → the DTCG source (.tokens.json)
14
+ * design_tokens.outputs[] → [{ format: ts|css, path: <file> }]
15
+ *
16
+ * Always safe: no source / no outputs → a clear no-op message, exit 0. A bad
17
+ * DTCG file or a write failure → exit 1 with the reason (it IS a build step).
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const yaml = require('js-yaml');
23
+ const UI = require('../utils/ui');
24
+ const TokensGenerator = require('../utils/tokens-generator');
25
+
26
+ const CONFIG_FILE = 'baldart.config.yml';
27
+
28
+ function loadConfig(cwd) {
29
+ const full = path.join(cwd, CONFIG_FILE);
30
+ try { return yaml.load(fs.readFileSync(full, 'utf8')) || {}; }
31
+ catch (_) { return null; }
32
+ }
33
+
34
+ async function tokensBuild(opts = {}) {
35
+ const cwd = opts.cwd || process.cwd();
36
+ const json = !!opts.json;
37
+ const config = loadConfig(cwd);
38
+
39
+ const emit = (obj, exit = 0) => {
40
+ if (json) process.stdout.write(JSON.stringify(obj) + '\n');
41
+ return exit;
42
+ };
43
+
44
+ if (!config) {
45
+ if (!json) UI.warning(`No (or malformed) ${CONFIG_FILE} — run \`npx baldart configure\` first.`);
46
+ return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: 'no-config' }, 1);
47
+ }
48
+
49
+ const source = config.paths && config.paths.design_tokens;
50
+ const outputs = (config.design_tokens && config.design_tokens.outputs) || [];
51
+
52
+ if (!source) {
53
+ if (!json) UI.info('No `paths.design_tokens` set — DTCG token SSOT not adopted on this project. Nothing to build.');
54
+ return emit({ schema: 'baldart.tokens-build/1', ok: true, skipped: 'no-source' });
55
+ }
56
+
57
+ const gen = new TokensGenerator(cwd);
58
+ if (!gen.detect(source)) {
59
+ if (!json) UI.warning(`design tokens source not found at \`${source}\`. Create it (e.g. via /design-system-init) then re-run.`);
60
+ return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: 'source-missing', source }, 1);
61
+ }
62
+ if (!outputs.length) {
63
+ if (!json) UI.warning('`design_tokens.outputs` is empty — nothing to generate. Add at least one { format, path }.');
64
+ return emit({ schema: 'baldart.tokens-build/1', ok: true, skipped: 'no-outputs', source });
65
+ }
66
+
67
+ const res = gen.build({ source, outputs });
68
+ if (!res.ok) {
69
+ if (!json) UI.error(`Token build failed: ${res.error}`);
70
+ return emit({ schema: 'baldart.tokens-build/1', ok: false, reason: res.error, source }, 1);
71
+ }
72
+
73
+ if (!json) {
74
+ UI.success(`Tokens built from ${source} (${res.tokenCount} tokens):`);
75
+ for (const p of res.written) UI.info(` ✓ ${p}`);
76
+ }
77
+ return emit({ schema: 'baldart.tokens-build/1', ok: true, source, written: res.written, tokenCount: res.tokenCount });
78
+ }
79
+
80
+ module.exports = { build: tokensBuild };
@@ -1320,6 +1320,12 @@ async function update(options = {}, unknownArgs = []) {
1320
1320
  const missingI18n = Object.keys(tpl.i18n || {})
1321
1321
  .filter((k) => !(k in (cur2.i18n || {})))
1322
1322
  .map((k) => `i18n.${k}`);
1323
+ // `design_tokens:` (since v4.65.0) — same nested-block contract.
1324
+ // Its only key `outputs` is an ARRAY, so (per the v4.46.0 array
1325
+ // lesson) we diff presence of the key, not deep value equality.
1326
+ const missingDesignTokens = Object.keys(tpl.design_tokens || {})
1327
+ .filter((k) => !(k in (cur2.design_tokens || {})))
1328
+ .map((k) => `design_tokens.${k}`);
1323
1329
  const allMissing = [
1324
1330
  ...missingPaths,
1325
1331
  ...missingFeatures,
@@ -1329,6 +1335,7 @@ async function update(options = {}, unknownArgs = []) {
1329
1335
  ...missingToolchain,
1330
1336
  ...missingToolchainCmds,
1331
1337
  ...missingI18n,
1338
+ ...missingDesignTokens,
1332
1339
  ];
1333
1340
  if (allMissing.length) {
1334
1341
  UI.newline();
@@ -0,0 +1,36 @@
1
+ # token-emitters
2
+
3
+ Per-format emitters for the DTCG token generator (`../tokens-generator.js`).
4
+ The DTCG `.tokens.json` (`paths.design_tokens` in `baldart.config.yml`) is the
5
+ design-token SSOT; each emitter turns the resolved token set into ONE
6
+ stack-native artifact. Same REGISTRY pattern as `lsp-adapters/`,
7
+ `routine-adapters/`, `tool-adapters/`, `toolchain-adapters/`.
8
+
9
+ ## Adding a format
10
+
11
+ 1. Create `<format>.js` exporting `module.exports = function emit({ resolved, nested, marker }) { return '<file contents>'; }`.
12
+ - `resolved`: `Map<dotId, { type, value }>` — fully ref-resolved leaf tokens, source order.
13
+ - `nested`: nested plain object of resolved **values** (DTCG tree minus `$type`/`$value`).
14
+ - `marker`: the generated-file banner line for your format (already comment-wrapped).
15
+ 2. Register it in `index.js`'s `EMITTERS` map.
16
+ 3. If the comment syntax differs from `//` and `/* */`, extend `markerFor()` in `index.js`.
17
+
18
+ That's it — `tokens-generator.js`, `baldart tokens build`, and `doctor` iterate
19
+ the registry; nothing else changes.
20
+
21
+ ## Invariants
22
+
23
+ - **Deterministic, diff-stable output**: emit in source order, no timestamps, no
24
+ randomness — so `isStale()` (on-disk vs fresh render byte-compare) is reliable.
25
+ - **Always emit the `marker`** as the first line — it is how humans and the
26
+ `framework-edit-gate` know the file is generated and must not be hand-edited.
27
+ - **Pure**: emitters never touch the filesystem; the generator writes.
28
+ - **Zero dependency**: no Style Dictionary, no npm — keep it portable (the build
29
+ path must run identically under Codex).
30
+
31
+ ## Shipped formats
32
+
33
+ | format | output |
34
+ |--------|--------|
35
+ | `ts` | `export const tokens = {…} as const;` + `export type Tokens` |
36
+ | `css` | `:root { --<dot-id-as-dashes>: <value>; }` |
@@ -0,0 +1,18 @@
1
+ // CSS emitter: DTCG → `:root { --<dot-id-as-dashes>: <value>; }`.
2
+ //
3
+ // Token id `color.action.primary` → `--color-action-primary`. Emits resolved
4
+ // values (refs collapsed) in source order so the output is diff-stable.
5
+
6
+ function cssVarName(dotId) {
7
+ return `--${dotId.replace(/\./g, '-')}`;
8
+ }
9
+
10
+ module.exports = function emitCss({ resolved, marker }) {
11
+ const lines = [];
12
+ for (const [id, tok] of resolved) {
13
+ lines.push(` ${cssVarName(id)}: ${tok.value};`);
14
+ }
15
+ return `${marker}\n:root {\n${lines.join('\n')}\n}\n`;
16
+ };
17
+
18
+ module.exports.cssVarName = cssVarName;
@@ -0,0 +1,47 @@
1
+ // Token emitter registry (since v4.65.0).
2
+ //
3
+ // DTCG (`.tokens.json`) is the design-token SSOT; each emitter turns the
4
+ // resolved token set into ONE stack-native artifact (a TS object, a CSS
5
+ // custom-property sheet, …). This is the same REGISTRY pattern as
6
+ // `lsp-adapters/`, `routine-adapters/`, `tool-adapters/`, `toolchain-adapters/`:
7
+ // every other layer iterates this map, so adding a format = drop a file +
8
+ // register it here. Zero npm dependency (no Style Dictionary) — see
9
+ // `framework/docs/COMPONENT-MANIFEST-LAYER.md`.
10
+ //
11
+ // An emitter exports a single function:
12
+ // emit({ resolved, nested, marker }) -> string (the file contents)
13
+ // where:
14
+ // resolved : Map<dotId, { type, value }> fully ref-resolved leaf tokens
15
+ // nested : nested plain object of resolved VALUES (DTCG tree minus $type/$value)
16
+ // marker : the "baldart-generated" banner line for this format
17
+
18
+ const ts = require('./ts');
19
+ const css = require('./css');
20
+
21
+ const EMITTERS = { ts, css };
22
+
23
+ /** Stable, format-appropriate generated-file banner. */
24
+ function markerFor(format) {
25
+ const text = 'baldart-generated from .tokens.json — do not edit (run `baldart tokens build`)';
26
+ if (format === 'css') return `/* ${text} */`;
27
+ return `// ${text}`; // ts / js / any C-style
28
+ }
29
+
30
+ /**
31
+ * Emit one output. Returns { ok, format, content } or { ok:false, error }.
32
+ * Pure — does not touch the filesystem (the caller writes).
33
+ */
34
+ function emit(format, { resolved, nested }) {
35
+ const fn = EMITTERS[format];
36
+ if (typeof fn !== 'function') {
37
+ return { ok: false, error: `unknown token output format: "${format}" (known: ${Object.keys(EMITTERS).join(', ')})` };
38
+ }
39
+ const marker = markerFor(format);
40
+ try {
41
+ return { ok: true, format, content: fn({ resolved, nested, marker }) };
42
+ } catch (e) {
43
+ return { ok: false, error: `emitter "${format}" failed: ${e.message}` };
44
+ }
45
+ }
46
+
47
+ module.exports = { emit, markerFor, knownFormats: () => Object.keys(EMITTERS) };
@@ -0,0 +1,32 @@
1
+ // TS emitter: DTCG → a typed `export const tokens = {...} as const;` object.
2
+ //
3
+ // Emits the nested tree of RESOLVED values (refs already collapsed), mirroring
4
+ // the DTCG group structure minus the $type/$value wrappers. Deterministic key
5
+ // order (insertion order of the source) so the output is diff-stable.
6
+
7
+ function literal(value) {
8
+ // Tokens resolve to strings (colors, dimensions, durations) or numbers.
9
+ if (typeof value === 'number') return String(value);
10
+ return JSON.stringify(String(value));
11
+ }
12
+
13
+ function render(node, indent) {
14
+ const pad = ' '.repeat(indent);
15
+ const padInner = ' '.repeat(indent + 1);
16
+ const keys = Object.keys(node);
17
+ const lines = keys.map((k) => {
18
+ const v = node[k];
19
+ // A safe JS identifier key can be bare; otherwise quote it (e.g. "2").
20
+ const key = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
21
+ if (v && typeof v === 'object') {
22
+ return `${padInner}${key}: ${render(v, indent + 1)},`;
23
+ }
24
+ return `${padInner}${key}: ${literal(v)},`;
25
+ });
26
+ return `{\n${lines.join('\n')}\n${pad}}`;
27
+ }
28
+
29
+ module.exports = function emitTs({ nested, marker }) {
30
+ const body = render(nested, 0);
31
+ return `${marker}\nexport const tokens = ${body} as const;\n\nexport type Tokens = typeof tokens;\n`;
32
+ };
@@ -0,0 +1,165 @@
1
+ // Tokens generator — thin orchestrator over the DTCG SSOT (since v4.65.0).
2
+ //
3
+ // DTCG (`.tokens.json`, W3C Design Tokens Community Group: `$type`/`$value`,
4
+ // `{ref}` aliases) is the design-token source of truth. This class reads it,
5
+ // resolves aliases, and fans out to the per-format emitters in
6
+ // `token-emitters/` to write the stack-native artifacts (tokens.ts, CSS vars).
7
+ // Zero npm dependency — DTCG parsing is plain JSON traversal. Mirrors the thin-
8
+ // wrapper shape of `graphify-installer.js` (detect / isStale / build).
9
+ //
10
+ // Authoritative design: framework/docs/COMPONENT-MANIFEST-LAYER.md.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const emitters = require('./token-emitters');
15
+
16
+ class TokensGenerator {
17
+ constructor(cwd = process.cwd()) { this.cwd = cwd; }
18
+
19
+ /** True when the DTCG source file exists. */
20
+ detect(source) {
21
+ if (!source) return false;
22
+ try { return fs.statSync(path.join(this.cwd, source)).isFile(); }
23
+ catch (_) { return false; }
24
+ }
25
+
26
+ /** Read + JSON.parse the DTCG source. Throws a clear error on bad input. */
27
+ load(source) {
28
+ const abs = path.join(this.cwd, source);
29
+ let raw;
30
+ try { raw = fs.readFileSync(abs, 'utf8'); }
31
+ catch (e) { throw new Error(`cannot read design tokens at ${source}: ${e.message}`); }
32
+ try { return JSON.parse(raw); }
33
+ catch (e) { throw new Error(`${source} is not valid JSON: ${e.message}`); }
34
+ }
35
+
36
+ /**
37
+ * Flatten the DTCG tree to leaf tokens. A node is a token when it carries
38
+ * `$value`. `$type` is inherited from the nearest ancestor that declares it.
39
+ * Returns Map<dotId, { type, raw }> in source order.
40
+ */
41
+ flatten(tree) {
42
+ const out = new Map();
43
+ const walk = (node, prefix, inheritedType) => {
44
+ if (!node || typeof node !== 'object') return;
45
+ const type = Object.prototype.hasOwnProperty.call(node, '$type') ? node.$type : inheritedType;
46
+ if (Object.prototype.hasOwnProperty.call(node, '$value')) {
47
+ out.set(prefix, { type, raw: node.$value });
48
+ return;
49
+ }
50
+ for (const key of Object.keys(node)) {
51
+ if (key.startsWith('$')) continue; // $type / $description / $extensions
52
+ walk(node[key], prefix ? `${prefix}.${key}` : key, type);
53
+ }
54
+ };
55
+ walk(tree, '', undefined);
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Resolve `{a.b.c}` aliases to concrete values (chains + cycle guard).
61
+ * Returns Map<dotId, { type, value }> in the same order as `flat`.
62
+ */
63
+ resolve(flat) {
64
+ const resolved = new Map();
65
+ const resolveOne = (id, seen) => {
66
+ if (resolved.has(id)) return resolved.get(id);
67
+ const tok = flat.get(id);
68
+ if (!tok) throw new Error(`token reference {${id}} does not resolve to a known token`);
69
+ let value = tok.raw;
70
+ if (typeof value === 'string') {
71
+ const m = value.match(/^\{([^}]+)\}$/);
72
+ if (m) {
73
+ const ref = m[1];
74
+ if (seen.has(ref)) throw new Error(`cyclic token reference: ${[...seen, ref].join(' → ')}`);
75
+ const target = resolveOne(ref, new Set([...seen, id]));
76
+ value = target.value;
77
+ }
78
+ }
79
+ const entry = { type: tok.type, value };
80
+ resolved.set(id, entry);
81
+ return entry;
82
+ };
83
+ for (const id of flat.keys()) resolveOne(id, new Set());
84
+ return resolved;
85
+ }
86
+
87
+ /** Build the nested object of resolved VALUES (DTCG tree minus $wrappers). */
88
+ nest(resolved) {
89
+ const root = {};
90
+ for (const [id, tok] of resolved) {
91
+ const parts = id.split('.');
92
+ let cur = root;
93
+ for (let i = 0; i < parts.length - 1; i++) {
94
+ cur[parts[i]] = cur[parts[i]] || {};
95
+ cur = cur[parts[i]];
96
+ }
97
+ cur[parts[parts.length - 1]] = tok.value;
98
+ }
99
+ return root;
100
+ }
101
+
102
+ /**
103
+ * Produce { outputs: [{ format, path, content }], resolved } WITHOUT writing.
104
+ * Used by both build() and isStale().
105
+ */
106
+ render({ source, outputs }) {
107
+ const tree = this.load(source);
108
+ const flat = this.flatten(tree);
109
+ if (flat.size === 0) throw new Error(`${source} contains no DTCG tokens ($value leaves)`);
110
+ const resolved = this.resolve(flat);
111
+ const nested = this.nest(resolved);
112
+ const rendered = [];
113
+ for (const o of outputs || []) {
114
+ const r = emitters.emit(o.format, { resolved, nested });
115
+ if (!r.ok) throw new Error(r.error);
116
+ rendered.push({ format: o.format, path: o.path, content: r.content });
117
+ }
118
+ return { outputs: rendered, resolved };
119
+ }
120
+
121
+ /**
122
+ * Build: render + write every output. Returns { ok, written:[paths] } or
123
+ * { ok:false, error }.
124
+ */
125
+ build({ source, outputs }) {
126
+ let r;
127
+ try { r = this.render({ source, outputs }); }
128
+ catch (e) { return { ok: false, error: e.message }; }
129
+ const written = [];
130
+ for (const o of r.outputs) {
131
+ const abs = path.join(this.cwd, o.path);
132
+ try {
133
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
134
+ fs.writeFileSync(abs, o.content, 'utf8');
135
+ written.push(o.path);
136
+ } catch (e) {
137
+ return { ok: false, error: `failed writing ${o.path}: ${e.message}`, written };
138
+ }
139
+ }
140
+ return { ok: true, written, tokenCount: r.resolved.size };
141
+ }
142
+
143
+ /**
144
+ * isStale: true when any output on disk differs from a fresh render (i.e. a
145
+ * hand-edit to a generated file, or the source changed without a rebuild).
146
+ * Returns { stale, reasons:[{path, reason}] } or { stale:false } / error.
147
+ */
148
+ isStale({ source, outputs }) {
149
+ if (!this.detect(source)) return { stale: false, sourceMissing: true };
150
+ let r;
151
+ try { r = this.render({ source, outputs }); }
152
+ catch (e) { return { stale: true, error: e.message, reasons: [] }; }
153
+ const reasons = [];
154
+ for (const o of r.outputs) {
155
+ const abs = path.join(this.cwd, o.path);
156
+ let onDisk;
157
+ try { onDisk = fs.readFileSync(abs, 'utf8'); }
158
+ catch (_) { reasons.push({ path: o.path, reason: 'missing' }); continue; }
159
+ if (onDisk !== o.content) reasons.push({ path: o.path, reason: 'out-of-date' });
160
+ }
161
+ return { stale: reasons.length > 0, reasons };
162
+ }
163
+ }
164
+
165
+ module.exports = TokensGenerator;