@stonepandastudio/cairn 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -12,7 +12,7 @@ agreeing.
12
12
 
13
13
  ```bash
14
14
  npm i -D @stonepandastudio/cairn
15
- npx cairn init --stack backend --tracker jira-server --project-key MYPROJ --story-type Task
15
+ npx cairn init --stack nestjs,typeorm --tracker jira-server --project-key MYPROJ --story-type Task
16
16
  ```
17
17
 
18
18
  ## What v1 does
@@ -134,16 +134,17 @@ overwriting a hand edit is the destructive outcome.
134
134
 
135
135
  ### Workspace config (`repos.json`)
136
136
 
137
- - `repos[]` — name, path, stack. Per-repo settings live in each repo's own
138
- `cairn.config.json`; the inline `vars` blocks are a fallback used only until a repo
139
- has been through `cairn init`, so migration can happen one repo at a time.
137
+ - `repos[]` — name, path, stack (`"angular"` or a list `["nestjs", "typeorm"]`
138
+ framework first). Per-repo settings live in each repo's own `cairn.config.json`;
139
+ the inline `vars` blocks are a fallback used only until a repo has been through
140
+ `cairn init`, so migration can happen one repo at a time.
140
141
  - `pathAliases` — local → canonical. When one repo names a stub `execute-tests.md`
141
142
  where the others use `execute-test-plan.md`, aliasing folds them onto the cohort's
142
143
  names instead of reporting one `MISSING` and one `STRANDED` for the same file.
143
144
  - `shared[]` — glob rules. `cohort: "all"` compares every repo; `cohort: "stack"`
144
- compares backend-with-backend. `requires: { tracker: true }` skips repos with no
145
- tracker. A repo with no config has an *unknown* tracker, not an absent one, and is
146
- never skipped on that basis.
145
+ groups by the first stack entry (all `nestjs` repos together, whatever the ORM).
146
+ `requires: { tracker: true }` skips repos with no tracker. A repo with no config
147
+ has an *unknown* tracker, not an absent one, and is never skipped on that basis.
147
148
  - Anything not matched by a rule — `ai/contexts/`, `ai/progress/`, `ai/tasks/`,
148
149
  `DATABASE_SCHEMA.md` — is per-project by design and never inspected.
149
150
  `ai/INITIAL_PROMPT.md` is explicitly excluded: variants at ~0% shared content make
@@ -167,4 +168,4 @@ schema.json JSON Schema for cairn.config.json
167
168
  test/run.js dependency-free test runner
168
169
  ```
169
170
 
170
- No runtime dependencies. Node >= 18. `npm test` runs 46 tests.
171
+ No runtime dependencies. Node >= 18. `npm test` runs 51 tests.
package/lib/config.js CHANGED
@@ -13,7 +13,7 @@ const CAIRN_DIR = '_cairn';
13
13
  // generated directory from slowly acquiring hand-maintained state.
14
14
 
15
15
  const DEFAULTS = {
16
- stack: 'unknown',
16
+ stack: ['unknown'],
17
17
  tracker: { provider: 'none' },
18
18
  agents: {},
19
19
  vars: {},
@@ -21,6 +21,21 @@ const DEFAULTS = {
21
21
 
22
22
  class ConfigError extends Error {}
23
23
 
24
+ // `stack` is a list — the framework first, then composable add-ons
25
+ // (`["nestjs", "typeorm"]`). A bare string or a comma-separated string is
26
+ // accepted and normalized. The doctor's `cohort: "stack"` groups by the first
27
+ // entry; the v2 renderer layers a preset per entry, in order.
28
+ function normalizeStack(value) {
29
+ if (Array.isArray(value)) {
30
+ const list = value.map((s) => String(s).trim()).filter(Boolean);
31
+ return list.length ? list : ['unknown'];
32
+ }
33
+ if (typeof value === 'string' && value.trim()) {
34
+ return value.split(',').map((s) => s.trim()).filter(Boolean);
35
+ }
36
+ return ['unknown'];
37
+ }
38
+
24
39
  function configPath(repoPath) {
25
40
  return path.join(repoPath, CONFIG_NAME);
26
41
  }
@@ -110,6 +125,7 @@ function loadRepoConfig(repoPath, { fallbackVars = null, required = false } = {}
110
125
  const cfg = {
111
126
  ...DEFAULTS,
112
127
  ...raw,
128
+ stack: normalizeStack(raw.stack),
113
129
  tracker: normalizeTracker(raw.tracker),
114
130
  agents: raw.agents || {},
115
131
  vars: raw.vars || fallbackVars || {},
@@ -142,5 +158,6 @@ module.exports = {
142
158
  findRepoRoot,
143
159
  loadEnv,
144
160
  loadRepoConfig,
161
+ normalizeStack,
145
162
  parseEnv,
146
163
  };
@@ -20,7 +20,7 @@ const { matchRule, readFile } = require('./scan');
20
20
  const { buildNormalizer } = require('./normalize');
21
21
  const diff = require('./diff');
22
22
  const { makePaint, stripAnsi, table } = require('../paint');
23
- const { loadRepoConfig } = require('../config');
23
+ const { loadRepoConfig, normalizeStack } = require('../config');
24
24
  const { readManifest, statusFor } = require('../manifest');
25
25
 
26
26
  function hash(s) {
@@ -55,7 +55,8 @@ function resolveRepos(config, configDir) {
55
55
  return {
56
56
  name: entry.name,
57
57
  path: repoPath.replace(/\\/g, '/'),
58
- stack: entry.stack || repoConfig.stack,
58
+ // A list — framework first, then add-ons. `cohort: "stack"` groups by [0].
59
+ stack: normalizeStack(entry.stack || repoConfig.stack),
59
60
  vars: { ...(repoConfig.vars || {}), ...(entry.vars || {}) },
60
61
  tracker: repoConfig.tracker,
61
62
  managed: !repoConfig.unmanaged,
@@ -72,7 +73,9 @@ function cohortGroups(rule, repos) {
72
73
  if (rule.cohort === 'stack') {
73
74
  const byStack = new Map();
74
75
  for (const repo of repos) {
75
- const key = repo.stack || 'unknown';
76
+ // Compare at framework granularity — the shared contracts live there, and
77
+ // ORM/add-on differences are resolved by composing presets, not by cohort.
78
+ const key = (repo.stack && repo.stack[0]) || 'unknown';
76
79
  if (!byStack.has(key)) byStack.set(key, []);
77
80
  byStack.get(key).push(repo);
78
81
  }
@@ -94,7 +97,7 @@ function repoMeetsRequirements(repo, rule) {
94
97
  if (req.tracker === true && repo.managed && (!repo.tracker || repo.tracker.provider === 'none')) {
95
98
  return false;
96
99
  }
97
- if (req.stack && repo.stack !== req.stack) return false;
100
+ if (req.stack && !(repo.stack || []).includes(req.stack)) return false;
98
101
  return true;
99
102
  }
100
103
 
@@ -514,4 +517,4 @@ function main(argv = process.argv.slice(2)) {
514
517
  return 0;
515
518
  }
516
519
 
517
- module.exports = { main, analyze, analyzeManaged, resolveRepos, render, HELP, stripAnsi };
520
+ module.exports = { main, analyze, analyzeManaged, resolveRepos, cohortGroups, render, HELP, stripAnsi };
package/lib/init.js CHANGED
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
 
6
- const { CONFIG_NAME, configPath, cairnDir } = require('./config');
6
+ const { CONFIG_NAME, configPath, cairnDir, normalizeStack } = require('./config');
7
7
  const {
8
8
  emptyManifest,
9
9
  readManifest,
@@ -29,19 +29,29 @@ const { makePaint } = require('./paint');
29
29
 
30
30
  const SHIM_SOURCE = 'shims/jira.js';
31
31
  const SHIM_TARGET = '_cairn/scripts/jira.js';
32
- const LEGACY_SHIM_TARGET = 'ai/scripts/jira.js';
32
+
33
+ // Copied tracker-client filenames seen across the repos. `cairn init` replaces
34
+ // every one that exists with the shim — the snap-proof repos carry all three
35
+ // (`task-tracker.js` dispatching to `jira.js` / `youtrack.js`), and the docs
36
+ // that reference them by path keep working because the shim reads the same
37
+ // argv. A repo that never had one is left alone; the shim is never invented.
38
+ const LEGACY_SHIM_TARGETS = [
39
+ 'ai/scripts/jira.js',
40
+ 'ai/scripts/youtrack.js',
41
+ 'ai/scripts/task-tracker.js',
42
+ ];
33
43
 
34
44
  const HELP = `cairn init — make a repo cairn-managed
35
45
 
36
46
  Usage: cairn init [options]
37
47
 
38
48
  --repo <path> repo to initialise (default: cwd)
39
- --stack <name> backend | frontend | cli | ... (default: unknown)
49
+ --stack <names> comma-separated, framework first: nestjs,typeorm | angular | cli
40
50
  --tracker <provider> ${listProviders().join(' | ')} (default: none)
41
51
  --project-key <KEY> tracker project key, e.g. PROOF
42
52
  --story-type <name> parent issue type (default: Story)
43
53
  --subtask-type <name> child issue type (default: Sub-task)
44
- --no-shim do not vendor the ai/scripts/jira.js compatibility shim
54
+ --no-shim do not vendor or replace the ai/scripts/*.js client shims
45
55
  --force overwrite an existing cairn.config.json
46
56
  --dry-run print what would be written, write nothing
47
57
  `;
@@ -84,7 +94,7 @@ function buildConfig(args) {
84
94
  const cfg = {
85
95
  $schema: `./node_modules/${require('../package.json').name}/schema.json`,
86
96
  cairn: require('../package.json').version,
87
- stack: args.stack,
97
+ stack: normalizeStack(args.stack),
88
98
  tracker: { provider: args.tracker },
89
99
  };
90
100
  if (args.tracker !== 'none') {
@@ -140,10 +150,12 @@ function main(argv = process.argv.slice(2)) {
140
150
  const shim = readTemplate(SHIM_SOURCE);
141
151
  if (args.shim) {
142
152
  planned.push([SHIM_TARGET, shim, SHIM_SOURCE]);
143
- // Only replace the legacy path if a copied client is actually there. Creating
144
- // it in a repo that never had one would invent a path nothing references.
145
- if (fs.existsSync(path.join(repoPath, LEGACY_SHIM_TARGET))) {
146
- planned.push([LEGACY_SHIM_TARGET, shim, SHIM_SOURCE]);
153
+ // Only replace a legacy path if a copied client is actually there — creating
154
+ // one in a repo that never had it would invent a path nothing references.
155
+ for (const target of LEGACY_SHIM_TARGETS) {
156
+ if (fs.existsSync(path.join(repoPath, target))) {
157
+ planned.push([target, shim, SHIM_SOURCE]);
158
+ }
147
159
  }
148
160
  }
149
161
 
@@ -186,4 +198,4 @@ function main(argv = process.argv.slice(2)) {
186
198
  return 0;
187
199
  }
188
200
 
189
- module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGET, cairnDir };
201
+ module.exports = { main, buildConfig, HELP, SHIM_TARGET, LEGACY_SHIM_TARGETS, cairnDir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonepandastudio/cairn",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared AI workflow scaffolding for Stone Panda repos — issue tracker client and drift doctor.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/schema.json CHANGED
@@ -12,9 +12,12 @@
12
12
  "description": "Version of @stonepandastudio/cairn this repo is pinned to."
13
13
  },
14
14
  "stack": {
15
- "type": "string",
16
- "description": "Comparison cohort for the doctor and, from v2, the preset to render from.",
17
- "examples": ["backend", "frontend", "cli"]
15
+ "description": "Framework first, then composable add-ons. `cohort: \"stack\"` in the doctor groups by the first entry; the v2 renderer layers one preset per entry, in order. A bare or comma-separated string is also accepted.",
16
+ "anyOf": [
17
+ { "type": "string" },
18
+ { "type": "array", "items": { "type": "string" }, "minItems": 1 }
19
+ ],
20
+ "examples": ["angular", ["nestjs", "typeorm"], "cli"]
18
21
  },
19
22
  "tracker": {
20
23
  "type": "object",