@skitterbyte/skitterspec 18.0.0 → 19.0.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/src/init.js CHANGED
@@ -153,7 +153,7 @@ function assertComposedAssets() {
153
153
  const SPEC_MARKER_START = '<!-- skitterspec:start -->'
154
154
  const SPEC_MARKER_END = '<!-- skitterspec:end -->'
155
155
 
156
- const report = { created: [], updated: [], skipped: [], removed: [], customized: [], healed: [], warnings: [] }
156
+ const report = { created: [], updated: [], skipped: [], refused: [], removed: [], customized: [], healed: [], warnings: [] }
157
157
 
158
158
  function resetReport() {
159
159
  for (const k of Object.keys(report)) report[k].length = 0
@@ -293,6 +293,25 @@ function writeFile(dir, target, content, { force }) {
293
293
  if (link && link.isSymbolicLink() && !fs.existsSync(target)) {
294
294
  fs.unlinkSync(target)
295
295
  }
296
+ // A LIVE symlink is the opposite case, and `--force` is what makes it
297
+ // dangerous: `writeFileSync` follows the link, so forcing would write composed
298
+ // content — seam markers resolved, provider text spliced in — straight through
299
+ // it and into whatever it points at. In a checkout that dogfoods its own
300
+ // assets that is `packages/*/assets`, i.e. the SOURCE the link exists to keep
301
+ // live. Refuse: the staleness `--force` was reached for is a smaller problem
302
+ // than corrupting the file it would overwrite.
303
+ //
304
+ // WHAT WOULD MAKE THIS LIE: a HARD link. It has no distinguishing lstat — it
305
+ // simply is the file — so it takes the same corrupting path and nothing here
306
+ // can see it. Out of scope deliberately, and said out loud rather than left to
307
+ // be discovered; nothing in this project's install creates one.
308
+ //
309
+ // It cannot fire in an ordinary consumer install, because nothing there is
310
+ // linked — `skitterspec update` writes copies by design.
311
+ if (force && link && link.isSymbolicLink() && fs.existsSync(target)) {
312
+ report.refused.push(rel(dir, target))
313
+ return
314
+ }
296
315
  if (fs.existsSync(target)) {
297
316
  if (!force) {
298
317
  report.skipped.push(rel(dir, target))
@@ -499,6 +518,67 @@ function trustWorktreeRoot(dir) {
499
518
  }
500
519
  }
501
520
 
521
+ // Is the CLAUDE.md section this project has installed the one we ship?
522
+ //
523
+ // THREE answers, not two. `differs` deliberately does NOT mean "stale": the
524
+ // block is a COPY, so a difference is either an out-of-date copy or the user's
525
+ // own edit, and from here those read identically. Rule 4 of
526
+ // `.claude/rules/negative-checks.md` — route the unknown case to the harmless
527
+ // branch, which here means reporting a difference and naming the fix rather
528
+ // than accusing them of being behind.
529
+ //
530
+ // WHAT WOULD FOOL THIS: absent markers mean the section was never installed, OR
531
+ // that someone stripped it deliberately (`stripClaudeMdSection` exists and is
532
+ // reachable from `reset`). Neither is a fault, so both answer `not installed`
533
+ // and neither is reported as a problem.
534
+ function claudeMdSectionState(dir) {
535
+ const target = path.join(dir, 'CLAUDE.md')
536
+ if (!fs.existsSync(target)) return 'not installed'
537
+ const existing = fs.readFileSync(target, 'utf8')
538
+ if (!existing.includes(SPEC_MARKER_START) || !existing.includes(SPEC_MARKER_END)) {
539
+ return 'not installed'
540
+ }
541
+ const shipped = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
542
+ const start = existing.indexOf(SPEC_MARKER_START) + SPEC_MARKER_START.length
543
+ const installed = existing.slice(start, existing.indexOf(SPEC_MARKER_END)).trim()
544
+ return installed === shipped ? 'fresh' : 'differs'
545
+ }
546
+
547
+ // `update --check`: say what `update` would change, write nothing, exit 0.
548
+ // It reports; `update` without the flag stays the only thing that touches a
549
+ // file. This exists because the section is a copy and a copy goes quietly out
550
+ // of date — this repo's own was a whole spec behind the template it ships,
551
+ // through a spec about that template, with every test green.
552
+ function checkSync(dir, { claudeMd = true, log = console.log } = {}) {
553
+ if (!fs.existsSync(dir)) throw new Error(`target dir does not exist: ${dir}`)
554
+ const manifest = readManifest(dir)
555
+ const rows = []
556
+ // Mirror `resyncManagedFile`'s decision exactly rather than re-deriving it:
557
+ // missing → it would create; customized → it would KEEP yours and say so;
558
+ // pristine → it would write only when the shipped content actually differs.
559
+ for (const { relPath, abs, bundled } of managedTargets(dir)) {
560
+ const state = managedState(dir, relPath, manifest, bundled)
561
+ if (state === 'missing') rows.push([relPath, 'missing — would be created'])
562
+ else if (state === 'customized') rows.push([relPath, 'your edit — kept (--force overwrites)'])
563
+ else if (fs.readFileSync(abs, 'utf8') !== bundled) rows.push([relPath, 'out of date — would be updated'])
564
+ }
565
+ const section = claudeMd ? claudeMdSectionState(dir) : 'fresh'
566
+ // A healthy area says NOTHING. A report that lists what is already fine is a
567
+ // report people learn to skim, and then the one line that mattered is missed.
568
+ if (section === 'differs') {
569
+ rows.push([
570
+ 'CLAUDE.md (spec workflow section)',
571
+ 'differs from the shipped one — `update` would replace it (it is a copy, so this is either your edit or an out-of-date one)',
572
+ ])
573
+ }
574
+ if (!rows.length) log('skitterspec update --check: everything is up to date.')
575
+ else {
576
+ log('skitterspec update --check: `skitterspec update` would:')
577
+ for (const [name, why] of rows) log(` ${name} — ${why}`)
578
+ }
579
+ return { rows, section }
580
+ }
581
+
502
582
  function installClaudeMd(dir, { mode }) {
503
583
  const section = fs.readFileSync(path.join(ASSETS, 'claude-md-section.md'), 'utf8').trim()
504
584
  const block = `${SPEC_MARKER_START}\n${section}\n${SPEC_MARKER_END}\n`
@@ -681,6 +761,15 @@ function printReport(dir, mode, { diff = false } = {}) {
681
761
  )
682
762
  line('manifest repaired', report.healed)
683
763
  line('unchanged', report.skipped)
764
+ if (report.refused.length) {
765
+ process.stdout.write('\nrefused (a symlink — writing would overwrite what it points at):\n')
766
+ for (const it of report.refused) process.stdout.write(` ${it}\n`)
767
+ process.stdout.write(
768
+ ' These are links into the shipped assets. --force would follow them and\n' +
769
+ ' write composed content into the source. Unlink one to take the copy\n' +
770
+ ' (rm <path>, then re-run), or leave it linked and edit the asset.\n',
771
+ )
772
+ }
684
773
  if (report.warnings.length) {
685
774
  process.stdout.write('\nwarnings:\n')
686
775
  for (const w of report.warnings) process.stdout.write(` ! ${w}\n`)
@@ -764,6 +853,10 @@ async function init({ dir, force, claudeMd, mode, isolation, workspaceMode, gati
764
853
 
765
854
  module.exports = {
766
855
  init,
856
+ // A snapshot of the last run's report, for tests that need to assert on what a
857
+ // run DECIDED rather than only on what it left on disk. Copied, so a caller
858
+ // cannot mutate the live report between phases of a run.
859
+ lastReport: () => JSON.parse(JSON.stringify(report)),
767
860
  SKILLS,
768
861
  COMMANDS,
769
862
  RULES,
@@ -774,6 +867,8 @@ module.exports = {
774
867
  writeManifest,
775
868
  managedTargets,
776
869
  managedState,
870
+ claudeMdSectionState,
871
+ checkSync,
777
872
  isExistingSetup,
778
873
  resync,
779
874
  reset,
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Reuben Greaves
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.